agent-harness 0.38.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07b7223207e544b8728f23b9800ddc774b0c70d16cdd899ccac23550e4a10270
4
- data.tar.gz: d004705a37d7d83fa6e27f16f2b3593ffb81a06f30980e4aa699a92bb9f06237
3
+ metadata.gz: ee00d0e61838facc87483e6bdcac764ed26dc5bb227a15075b4e8f0daa8983d4
4
+ data.tar.gz: 6034f1733639711ad2b7df6bf9c26da839d45c26d1ddb6b0b4e5ba413854fc8a
5
5
  SHA512:
6
- metadata.gz: 9d59ea2adf8708b5eb6177fa2314d01e6eb83cb5b8d7748ccc19b7bc07394b1edea040015071df52d53ffb39b1684920092d393a94eeb690058356e05a4eafe5
7
- data.tar.gz: d9851948407b188ed95b18413b1eb647245cfe76ea0770e1952972c2a3f83c2a8bd979c90161cc04d4af78241d7dcc83f197451523af233314c28b8d853f9f12
6
+ metadata.gz: 130a2897522e8b1aa73b2a9cf6094bd24b3b529696445e84356a78faa02540e0c4c2677602e306fca60e1f1a3040e48c04b3ff0a9ca28f144a24de6fc3fa5887
7
+ data.tar.gz: 647ab39ee27728d28a3441ccbbd84edd3521e7e434a120ac236e411849b22aa431ae7fc6dbe499ec8c525aee6159c9fdba42a5ee8ff7ec329281dd7ea3a4ad33
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.38.0"
2
+ ".": "0.40.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@
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
+
15
+ ## [0.39.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.38.0...agent-harness/v0.39.0) (2026-09-25)
16
+
17
+
18
+ ### Features
19
+
20
+ * design: Define the Provider-Neutral API Execution Contract (RDR-072) ([#438](https://github.com/viamin/agent-harness/issues/438)) ([ae2fc6b](https://github.com/viamin/agent-harness/commit/ae2fc6bafad54ac35bb9d30a32d253c68a7396f2))
21
+
8
22
  ## [0.38.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.37.5...agent-harness/v0.38.0) (2026-09-24)
9
23
 
10
24
 
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
@@ -738,6 +803,12 @@ Health checks run five steps per provider: registration, CLI availability, authe
738
803
 
739
804
  ## Development
740
805
 
806
+ The proposed provider-neutral API execution boundary for embeddings, chat,
807
+ structured output, usage, and optional persistence is documented in the
808
+ [Provider-Neutral API Execution Contract](docs/provider-neutral-api-execution-contract.md).
809
+ It is a docs-only design boundary; capability support requires the release
810
+ evidence described there.
811
+
741
812
  ```bash
742
813
  # Install dependencies
743
814
  bin/setup
@@ -0,0 +1,532 @@
1
+ # Provider-Neutral API Execution Contract
2
+
3
+ This document records the design investigation for
4
+ [RDR-072](https://github.com/viamin/paid/blob/01672a9992853e89bcbd272b720ac9f1da73cba0/docs/rdrs/RDR-072-api-conversation-delegation.md).
5
+ It is the upstream contract for incremental embedding, chat, structured-output,
6
+ usage, and optional conversation-persistence work.
7
+
8
+ ## Status and rollout boundary
9
+
10
+ This is a design contract, not a claim that the described API is implemented.
11
+ RDR-072's rollout guard is **docs-only now**, so this change adds no runtime
12
+ behavior or dependency. Each capability below needs its own failing-first
13
+ contract tests, implementation, release evidence, and downstream adoption
14
+ evidence before a caller enables it.
15
+
16
+ Existing CLI and subscription behavior remains the default. Existing
17
+ `TextTransport`, `OpenAICompatibleTransport`, `Conversation`, and `Response`
18
+ interfaces remain available, but they do not yet satisfy this contract. A
19
+ caller must not infer support from a gem version or issue closure.
20
+
21
+ Normative words such as MUST and MUST NOT describe the future public boundary.
22
+
23
+ ## Ownership boundary
24
+
25
+ AgentHarness owns protocol translation and one bounded provider request:
26
+
27
+ - capability discovery and explicit unsupported outcomes;
28
+ - provider-specific request and response translation;
29
+ - request retries within caller-supplied limits;
30
+ - normalized stream events, results, errors, and attempt reports; and
31
+ - stable attempt and tool-call identities at its public boundary.
32
+
33
+ The caller owns application authority and durable policy:
34
+
35
+ - tenant and actor identity, authorization, and visible tools;
36
+ - candidate order, credentials, allowed models, and fallback notices;
37
+ - budgets, cancellation initiation, runner changes, and workflow recovery;
38
+ - durable usage attribution and idempotent ingestion of attempt reports;
39
+ - tool confirmation and side-effect reconciliation; and
40
+ - application conversation/message identity and audit records.
41
+
42
+ The harness MUST NOT select an unlisted fallback, borrow global credentials,
43
+ change authentication mode, execute a tool, or replay a completed tool result.
44
+ Keeping the caller's chat loop over this normalized transport is an acceptable
45
+ final architecture.
46
+
47
+ ## Common execution request
48
+
49
+ All operations use an immutable, request-local configuration. The eventual
50
+ Ruby API MAY use value objects rather than hashes, but it MUST expose these
51
+ semantics:
52
+
53
+ ```ruby
54
+ request = {
55
+ request_id: "paid-generation-018f...", # caller-generated, stable on redelivery
56
+ operation: :chat, # :chat, :embedding, or :schema
57
+ candidates: [ # ordered; first entry is initial
58
+ {
59
+ provider: :anthropic,
60
+ model: "claude-sonnet-4-5",
61
+ protocol: :messages, # optional verified protocol
62
+ authentication_mode: :api_key,
63
+ endpoint: "https://llm-proxy.example/v1",
64
+ headers: {"X-Tenant-Route" => "tenant-123"},
65
+ credentials: {api_key: anthropic_secret}
66
+ },
67
+ {
68
+ provider: :openai,
69
+ model: "gpt-5",
70
+ protocol: :responses,
71
+ authentication_mode: :api_key,
72
+ endpoint: "https://api.openai.com/v1",
73
+ headers: {},
74
+ credentials: {api_key: openai_secret}
75
+ }
76
+ ],
77
+ fallback: {on_error_categories: [:transient]},
78
+ timeout: {connect_seconds: 5, read_seconds: 60},
79
+ retry: {max_attempts: 3, base_delay_seconds: 0.25, max_delay_seconds: 2},
80
+ cancellation: cancellation_token,
81
+ metadata: {tenant_id: "tenant-123", workflow_id: "workflow-456"}
82
+ }
83
+ ```
84
+
85
+ `candidates` MUST contain at least one complete candidate record. Its first
86
+ entry is the initial candidate; later entries are the only authorized fallback
87
+ order. A request that does not authorize fallback supplies a one-entry array.
88
+ `fallback.on_error_categories` is the caller-selected allowlist of error
89
+ categories that permit advancing to the next entry; an absent or empty
90
+ allowlist disables fallback. Candidate records, their order, and the allowlist
91
+ are immutable for the lifetime of the request. The observer described below is
92
+ a request-local callback or equivalent API argument because callbacks are not
93
+ part of a serializable request document.
94
+
95
+ `request_id` identifies one logical operation. Every physical outbound request
96
+ gets a distinct `attempt_id`. Redelivering an already reported attempt retains
97
+ its `attempt_id`; initiating another outbound request does not.
98
+
99
+ Credentials, endpoint, headers, timeouts, retry limits, and cancellation are
100
+ request-local. Implementations MUST prevent concurrent requests from observing
101
+ one another's credentials or headers. They MUST reject reserved header
102
+ overrides that would conflict with the selected protocol's authentication.
103
+ Logs and errors MUST NOT contain credentials, authorization headers, message
104
+ bodies, tool arguments, or full provider responses.
105
+
106
+ The selected candidate's provider, model, protocol, endpoint, and authentication
107
+ mode MUST be the values actually used. The harness MUST return a configuration
108
+ or unsupported outcome instead of silently substituting any of them.
109
+
110
+ ## Capability discovery and unsupported outcomes
111
+
112
+ Support is declared per operation, provider, protocol, model when known, and
113
+ authentication mode. It is not a single provider-wide Boolean. The discovery
114
+ result has one of three states:
115
+
116
+ - `supported`: the requested combination is verified;
117
+ - `unsupported`: it is known not to work, with a stable reason; or
118
+ - `unknown`: it has not been verified and MUST NOT be routed as supported.
119
+
120
+ Stable unsupported reasons initially include:
121
+
122
+ - `operation_not_supported`;
123
+ - `protocol_not_supported`;
124
+ - `authentication_mode_not_supported`;
125
+ - `custom_endpoint_not_supported`;
126
+ - `custom_headers_not_supported`;
127
+ - `streaming_not_supported`;
128
+ - `tools_not_supported`;
129
+ - `structured_output_not_supported`; and
130
+ - `state_restoration_not_supported`.
131
+
132
+ An unsupported result is a normal, non-retryable outcome carrying the requested
133
+ scope and reason. It is not a generic provider failure. Callers MAY choose
134
+ another candidate only from their own ordered list.
135
+
136
+ ## Normalized chat contract
137
+
138
+ ### Messages
139
+
140
+ The input is an ordered array. Each message has a stable caller-owned `id`, one
141
+ of `system`, `user`, `assistant`, or `tool` as its `role`, and an ordered
142
+ `content` array. Content parts initially support `text`; unimplemented media
143
+ parts produce an explicit unsupported outcome. Assistant messages MAY contain
144
+ tool calls. Tool messages MUST reference exactly one `tool_call_id`.
145
+
146
+ Provider-specific wire roles, system-message placement, and content block
147
+ shapes stay behind the harness boundary. The harness MUST preserve ordering,
148
+ empty assistant text accompanying tool calls, and unknown versus absent data.
149
+
150
+ ### Tools
151
+
152
+ Tool definitions contain a caller-owned name, description, and JSON Schema
153
+ input definition. A normalized tool call contains:
154
+
155
+ ```ruby
156
+ {
157
+ id: "toolcall_018f...", # stable harness ID
158
+ provider_id: "call_abc", # optional provider correlation only
159
+ name: "search_documents",
160
+ arguments_json: "{\"query\":\"Ruby\"}"
161
+ }
162
+ ```
163
+
164
+ The stable `id` is generated before the call is exposed or persisted and MUST
165
+ survive export/import. `provider_id` is not a durable application identifier.
166
+ Arguments remain JSON text until parsing succeeds; malformed arguments produce
167
+ a classified parse failure and are never executed by the harness. Tool results
168
+ reference the stable ID. Redelivery or restoration MUST skip a tool call when a
169
+ completed result for that ID exists.
170
+
171
+ The transport never decides approval and never executes application tools.
172
+
173
+ ### Streaming
174
+
175
+ The stream is ordered and contains these normalized event types:
176
+
177
+ - `response_started` with request and attempt identity;
178
+ - `text_delta` with appended text;
179
+ - `tool_call_started`, `tool_call_delta`, and `tool_call_completed`;
180
+ - `usage_updated` when defensible cumulative or delta usage is available;
181
+ - `response_completed` with the final normalized result; and
182
+ - `response_failed` or `response_cancelled` with the partial result.
183
+
184
+ Every event carries `request_id`, `attempt_id`, and a monotonically increasing
185
+ `sequence`. A terminal event occurs exactly once per attempt. Callback failure
186
+ requests cancellation and surfaces as a caller error; it MUST NOT be converted
187
+ to provider success.
188
+
189
+ If a stream ends after emitting content, the terminal result has
190
+ `status: :partial`, retains received text and completed tool calls, and carries
191
+ the classified error. Incomplete tool calls are marked incomplete and MUST NOT
192
+ be executed. A partial attempt is never transparently retried: already emitted
193
+ content cannot be withdrawn. The caller must explicitly decide whether a new
194
+ attempt is safe and how to represent the abandoned partial message.
195
+
196
+ ### Result
197
+
198
+ A completed chat or schema result contains:
199
+
200
+ ```ruby
201
+ {
202
+ request_id: "paid-generation-018f...",
203
+ status: :succeeded, # :succeeded, :partial, :failed, or :cancelled
204
+ provider: :anthropic,
205
+ model: "claude-sonnet-4-5",
206
+ authentication_mode: :api_key,
207
+ content: "...",
208
+ parsed: nil,
209
+ tool_calls: [],
210
+ finish_reason: :stop,
211
+ attempts: [],
212
+ usage: {input_tokens: 12, output_tokens: 8, total_tokens: 20},
213
+ provider_request_id: nil,
214
+ error: nil
215
+ }
216
+ ```
217
+
218
+ `usage` aggregates only the attached attempt reports. Missing provider values
219
+ remain `nil`; they are not coerced to zero. `provider_request_id` is optional
220
+ diagnostic correlation and is not the accounting identity.
221
+
222
+ ## Structured-output contract
223
+
224
+ A schema operation adds a JSON Schema and optional schema name to the chat
225
+ request. The capability check MUST cover the selected provider/model/protocol
226
+ and requested schema mode. JSON-only mode is not equivalent to schema-enforced
227
+ output and MUST be reported separately.
228
+
229
+ On success, `content` preserves provider JSON text and `parsed` contains the
230
+ parsed Ruby value. Invalid JSON or schema mismatch is a classified,
231
+ non-transient result with the original text retained. The harness MUST NOT
232
+ silently strip fences, repair content, issue a second model call, or switch to
233
+ a different credential to make parsing succeed.
234
+
235
+ ## Embedding contract
236
+
237
+ An embedding request supplies one string or an ordered array of strings,
238
+ provider/model candidate, and optional dimensions. Its result preserves input
239
+ order and contains dense vectors, the model actually used, per-attempt usage,
240
+ and optional provider request correlation. One input returns one vector;
241
+ multiple inputs return an equal-length vector array.
242
+
243
+ Empty batches, unsupported dimensions, unsupported input media, and batch-size
244
+ limits are explicit configuration or unsupported outcomes. The first release
245
+ need not cover multimodal or provider-side batch APIs. Vector persistence and
246
+ similarity search remain caller responsibilities.
247
+
248
+ ## Attempts, usage, retries, and cancellation
249
+
250
+ Each physical provider request produces an attempt report, including failed and
251
+ cancelled attempts:
252
+
253
+ ```ruby
254
+ {
255
+ attempt_id: "attempt_018f...",
256
+ request_id: "paid-generation-018f...",
257
+ number: 2,
258
+ provider: :anthropic,
259
+ model: "claude-sonnet-4-5",
260
+ status: :failed,
261
+ started_at: "2026-09-25T12:00:00Z",
262
+ finished_at: "2026-09-25T12:00:01Z",
263
+ usage: {input_tokens: nil, output_tokens: nil, total_tokens: nil},
264
+ cost: nil,
265
+ provider_reported: false,
266
+ error: {category: :transient, code: :service_unavailable}
267
+ }
268
+ ```
269
+
270
+ Attempt reports are delivered both with the final result and through an
271
+ observer so durable accounting can persist an attempt even when no message is
272
+ created. Callers deduplicate on `attempt_id`. Cost identifies its source as
273
+ provider-reported or harness-estimated; unknown cost remains `nil`.
274
+
275
+ Only errors classified `transient` are eligible for bounded request retry:
276
+ connection failure, timeout before a partial stream, rate limit, server error,
277
+ service unavailable, and overload. Authentication, authorization, billing,
278
+ invalid request, unsupported capability, invalid schema, context length,
279
+ configuration, and cancellation are non-retryable.
280
+
281
+ The supplied `max_attempts` includes the first outbound request and is the total
282
+ physical-attempt budget across all candidates; changing candidates does not
283
+ reset it. It MUST be a positive integer; the harness rejects zero, negative, or
284
+ non-integer values as configuration errors before making an outbound request.
285
+ Delay and provider `retry-after` handling remain within the supplied bounds.
286
+ Cancellation is checked before an attempt, during backoff, while reading a
287
+ stream, and before returning success. It stops further attempts and returns a
288
+ cancelled terminal outcome with any partial usage. Cancellation does not prove
289
+ that the provider stopped processing or billing the request.
290
+
291
+ The harness owns the complete attempt sequence; there is exactly one retry
292
+ owner. RubyLLM's Faraday retry middleware resends the current candidate inside
293
+ the transport before a classified error can return to the harness, so
294
+ middleware-driven retries cannot honor the fallback-first order below and would
295
+ spend the shared budget on one candidate invisibly to the observer. An adapter
296
+ backed by RubyLLM MUST therefore disable its retry middleware with
297
+ `max_retries: 0`, overriding the default three retries, so that one adapter
298
+ call performs exactly one physical outbound request. The harness then issues
299
+ one adapter call per attempt — initial, retry, or fallback — and applies the
300
+ shared `max_attempts` budget, cancellation checks, backoff, and provider
301
+ `retry-after` between calls. No component may retry beneath the harness, and
302
+ the harness MUST NOT delegate this sequencing to middleware or another nested
303
+ loop. Existing conductor retry/provider switching is not part of an API
304
+ request's internal retry budget and MUST be disabled or bypassed for a
305
+ migrated scope.
306
+
307
+ ## Caller-controlled fallback
308
+
309
+ Fallback candidates are the entries after the first entry in the request's
310
+ ordered `candidates` array. Each is a complete request-local candidate record,
311
+ including credentials and endpoint/header overrides. A candidate change is a
312
+ new caller-authorized selection, not a hidden retry. Before advancing, the
313
+ harness invokes the request-local observer with the current and next candidate
314
+ identities and the classified error, so the caller can cancel the change or
315
+ issue a notice. The harness reports every candidate attempt.
316
+
317
+ Fallback is allowed only for caller-selected error categories. It never occurs
318
+ after a partial stream without a new explicit caller decision, never crosses
319
+ authentication modes implicitly, and never replays completed tools. The
320
+ harness MUST NOT use RubyLLM global fallback or global credential configuration
321
+ where it could exceed this list or leak request-local configuration.
322
+
323
+ Fallback takes precedence over retry when both are eligible. After a failed
324
+ attempt, the harness MUST apply this order:
325
+
326
+ 1. Stop if cancellation was requested or any partial stream was exposed.
327
+ 2. If the budget has capacity, the error category is in
328
+ `fallback.on_error_categories`, and another candidate remains, notify the
329
+ observer and advance to that candidate.
330
+ 3. Otherwise, retry the current candidate only when the error is retryable and
331
+ the shared `max_attempts` budget has capacity.
332
+ 4. Otherwise, return the terminal error.
333
+
334
+ The outbound request after either advancing or retrying consumes one physical
335
+ attempt from the same budget. Candidates are never revisited, and a failed
336
+ observer notification or observer cancellation stops before that request.
337
+ Consequently, the example request attempts Anthropic once and then OpenAI after
338
+ an eligible `transient` failure; only OpenAI can consume the remaining
339
+ same-candidate retry budget.
340
+
341
+ ## Error contract
342
+
343
+ Every terminal error exposes a stable category and code, retry eligibility,
344
+ provider/model identity, optional sanitized status and request ID, and partial
345
+ result/usage where available. Categories are:
346
+
347
+ | Category | Examples | Request retry |
348
+ | --- | --- | --- |
349
+ | `transient` | timeout, connection failure, 429, 5xx, overload | bounded |
350
+ | `authentication` | missing or rejected credential | never |
351
+ | `authorization` | credential lacks access | never |
352
+ | `billing` | inactive billing account or payment required | never |
353
+ | `configuration` | invalid endpoint/header/model or local configuration | never |
354
+ | `invalid_request` | malformed or semantically invalid provider request | never |
355
+ | `context_length` | request exceeds the model context window | never |
356
+ | `unsupported` | operation or option not implemented | never |
357
+ | `invalid_response` | malformed JSON, schema/tool parse failure | never |
358
+ | `cancelled` | caller cancellation | never |
359
+ | `caller` | callback or local input failure | never |
360
+ | `unknown` | provider failure that cannot be classified confidently | never |
361
+
362
+ Adapters MUST use the following category and code mappings. A provider-specific
363
+ status or error name may be retained as sanitized metadata, but MUST NOT replace
364
+ these values or change fallback eligibility.
365
+
366
+ | Failure | Category | Code |
367
+ | --- | --- | --- |
368
+ | Connection failure | `transient` | `connection_failed` |
369
+ | Timeout before a partial stream | `transient` | `timeout` |
370
+ | Rate limit | `transient` | `rate_limited` |
371
+ | Provider server error | `transient` | `server_error` |
372
+ | Service unavailable | `transient` | `service_unavailable` |
373
+ | Provider overload | `transient` | `overloaded` |
374
+ | Missing or rejected credential | `authentication` | `invalid_credential` |
375
+ | Credential lacks access | `authorization` | `permission_denied` |
376
+ | Billing account inactive or payment required | `billing` | `billing_unavailable` |
377
+ | Invalid provider request | `invalid_request` | `invalid_request` |
378
+ | Unsupported capability or option | `unsupported` | `unsupported_capability` |
379
+ | Structured output violates its schema | `invalid_response` | `invalid_schema` |
380
+ | Request exceeds the model context window | `context_length` | `context_length_exceeded` |
381
+ | Invalid endpoint, header, model, or local request configuration | `configuration` | `invalid_configuration` |
382
+ | Caller cancellation | `cancelled` | `cancelled` |
383
+
384
+ If a failure cannot be mapped confidently, the adapter MUST return `unknown` /
385
+ `unclassified_provider_error`, which is non-retryable, rather than guessing a
386
+ retryable category. A timeout or other error after a partial stream retains its
387
+ mapped category and code, but its per-error retry eligibility is `false` and the
388
+ fallback rule above prohibits automatic replay.
389
+
390
+ Provider error classes stay internal. Existing `ProviderError` is too broad to
391
+ drive this policy and existing `ErrorTaxonomy` was designed for CLI provider
392
+ switching, so neither is the future API retry contract without an explicit
393
+ mapping and contract tests.
394
+
395
+ ## State export, import, and restart safety
396
+
397
+ Plain Ruby state export is a versioned data document, not a serialized Ruby
398
+ object. It contains normalized messages, stable message/tool-call IDs, completed
399
+ tool results, pending tool calls and decisions, request IDs, and attempt reports.
400
+ It never contains credentials, callbacks, open streams, tool implementations,
401
+ authorization decisions, or mutable provider client objects.
402
+
403
+ Import validates the version and structure and reconstructs the normalized
404
+ conversation. The caller must re-supply tools, authorization, candidates,
405
+ credentials, observers, retry limits, and cancellation. Unknown versions fail
406
+ explicitly. Export/import round-trip tests must prove that completed tool IDs
407
+ are not re-executed and unknown usage remains unknown.
408
+
409
+ The restart guarantee is limited to checkpoints. A saved provider response or
410
+ tool result is skipped after restoration. A process crash after an external
411
+ tool side effect but before its result is durably saved can execute the tool
412
+ again. No transcript format can close that window. Tools therefore need a
413
+ caller-owned idempotency key derived from the stable tool-call ID, or explicit
414
+ reconciliation before replay. Provider requests have the same ambiguity when a
415
+ response is lost after the provider accepted it.
416
+
417
+ ## RubyLLM 2.0 mapping and alternatives
418
+
419
+ The investigation used RubyLLM 2.0.0. RubyLLM remains an implementation detail;
420
+ the harness public API MUST NOT expose its classes or private persistence
421
+ records.
422
+
423
+ | Contract area | RubyLLM 2.0 mapping | Decision or gap |
424
+ | --- | --- | --- |
425
+ | Chat/protocols | `RubyLLM.chat`, messages, tools, stream callbacks | Candidate adapter; normalize all values and errors |
426
+ | Schema | `with_schema`, `response.parsed`, model capability registry | Candidate; distinguish enforced schema from JSON mode |
427
+ | Embeddings | `RubyLLM.embed` and normalized vectors/usage | Candidate first capability; persistence remains in Paid |
428
+ | Custom headers | `with_headers` | Candidate; contract-test merging and secret redaction |
429
+ | Endpoint/credentials | provider configuration | Global mutable configuration is unsuitable; require request-local isolation or an upstream-supported client boundary |
430
+ | Retries | Faraday retry middleware, default three retries | Disable with `max_retries: 0` so one call is one physical attempt; the harness sequences bounded retry and fallback itself; never nest |
431
+ | Fallback | `with_fallbacks` and callbacks | Harness sequences candidates per call; `with_fallbacks` usable only if exact candidates, credentials, and per-advance observer control are preserved |
432
+ | Cancellation | chat cancellation and `CancelledError` | Adapt to the common token and retain partial stream state |
433
+ | Attempt usage | `usage.ruby_llm` per physical attempt | Useful facts, but the public payload has no stable attempt ID; harness must add one |
434
+ | Plain Ruby resume | transcript can be reconstructed manually | No documented state export/import API; implement normalized export/import outside RubyLLM |
435
+ | Rails resume | `acts_as_chat` transcript plus supporting records | Technically restart-safe at checkpoints; at-least-once side effects remain |
436
+
437
+ ### Optional Rails supporting tables
438
+
439
+ RubyLLM 2.0 owns `ruby_llm_models`, `ruby_llm_tool_calls`,
440
+ `ruby_llm_usages`, and `ruby_llm_batches`. Only the middle two are candidates
441
+ for early chat adoption; batches are out of scope, while the model registry is
442
+ needed only if capability/pricing lookup uses it.
443
+
444
+ `ruby_llm_tool_calls.tool_call_id` is unique and can preserve the provider tool
445
+ request/result link. It is acceptable only after tests prove that the harness's
446
+ stable tool ID maps without collision and Paid tenant scoping is enforced
447
+ through the owning application message/chat. The supporting table has no
448
+ standalone tenant column, so every read/write path must join through a
449
+ tenant-owned record and cross-tenant tests are mandatory.
450
+
451
+ `ruby_llm_usages` records one physical attempt and links polymorphically to the
452
+ application chat and optionally a message. It preserves failed/cancelled usage,
453
+ but its row ID and record class are private and its public instrumentation has
454
+ no stable attempt ID. Paid cannot use that row ID as the cross-system accounting
455
+ key. The harness-generated `attempt_id` must be persisted alongside Paid's
456
+ ledger, with an upstream-supported metadata column or mapping required before
457
+ adopting this table as the authoritative delivery source.
458
+
459
+ RubyLLM has no application-owned `Model`, `ToolCall`, `Usage`, or `Batch` model;
460
+ its record classes are explicitly implementation details. Paid keeps its chat
461
+ and message domain records, tenant ownership, message links, audit actor, and
462
+ authorization. A Rails adoption requires generated-migration review, backup,
463
+ production-snapshot rehearsal, backfill verification, rollback rehearsal, and
464
+ historical/pending-conversation tests. Reverting the gem is not a data rollback.
465
+
466
+ ### Persistence alternatives
467
+
468
+ 1. **Retain Paid persistence and loop over normalized transport.** Lowest
469
+ migration risk and the current recommendation. Implement plain Ruby
470
+ export/import and stable IDs in the harness.
471
+ 2. **Use RubyLLM tool-call and usage tables selectively.** Potentially removes
472
+ bookkeeping, but only after stable-attempt mapping, tenant-scoped access,
473
+ audit, and migration tests are complete.
474
+ 3. **Delegate the full loop and all supporting tables.** Not recommended now.
475
+ It does not yet demonstrate reduced maintenance across both repositories and
476
+ increases migration and recovery coupling.
477
+
478
+ The state investigation is therefore positive for checkpoint-based Rails
479
+ restoration and normalized plain Ruby reconstruction, and negative for
480
+ exactly-once recovery or an off-the-shelf plain Ruby export/import mechanism.
481
+
482
+ ## Current harness gaps and incremental delivery
483
+
484
+ The current transports already normalize basic text, tool calls, token totals,
485
+ and some HTTP errors. They do not provide request-local custom headers,
486
+ per-request timeout/retry/cancellation, structured output, embeddings, stable
487
+ attempt IDs, per-attempt usage, partial terminal results, state export/import,
488
+ or explicit capability outcomes. `Conversation` stores in-memory history and
489
+ provider formatters but has no serialization contract.
490
+
491
+ Ship capabilities independently in this order:
492
+
493
+ 1. common values, capability discovery, classified errors, and attempt reports;
494
+ 2. embeddings for verified operation/provider/custom-endpoint scopes;
495
+ 3. normalized non-streaming and streaming chat transport;
496
+ 4. structured output for verified model/protocol combinations;
497
+ 5. plain Ruby state round trips; and
498
+ 6. optional Rails persistence evaluation, then loop evaluation.
499
+
500
+ Each implementation issue starts with failing contract tests for request-local
501
+ credential isolation, custom endpoints/headers, unsupported outcomes,
502
+ classified errors, bounded non-nested retries, cancellation, partial streams,
503
+ stable IDs, and unknown usage where applicable. Provider-specific fixtures and
504
+ types stay behind the harness boundary.
505
+
506
+ ## Compatibility and release evidence
507
+
508
+ AgentHarness currently supports Ruby 3.2 and later and must remain usable as a
509
+ plain Ruby gem. RubyLLM 2.0.0 itself supports Ruby 3.1.3 and later, but adding it
510
+ would introduce Faraday, event-stream parsing, Schematist, Marcel, and Zeitwerk
511
+ runtime dependencies. A capability issue must measure and publish the resolved
512
+ dependency set and test the harness minimum Ruby version before adoption.
513
+
514
+ Rails and Active Record remain optional. Requiring `agent_harness` in a process
515
+ without Rails MUST NOT load Active Record, connect to a database, or require
516
+ supporting tables. Rails integration belongs behind an optional require and
517
+ adapter.
518
+
519
+ For every capability, release evidence must name:
520
+
521
+ - the first published, installable agent-harness version containing it;
522
+ - supported operation/provider/protocol/authentication combinations;
523
+ - upstream contract and integration test runs on the minimum Ruby version;
524
+ - verification of request-local secrets and absence of secret-bearing logs;
525
+ - retry/cancellation and attempt-accounting test evidence;
526
+ - the exact Paid and agent-image versions that consume the release; and
527
+ - retained paths and follow-up issues for combinations not migrated.
528
+
529
+ Paid issue `viamin/paid#4014` should receive this compatibility result, the
530
+ state-restoration conclusion, the stable-ID gap, and the per-capability release
531
+ evidence. Downstream adoption cannot proceed from this design issue closing or
532
+ from a Git tag alone.
@@ -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.38.0"
4
+ VERSION = "0.40.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,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.38.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
@@ -97,6 +131,7 @@ files:
97
131
  - Rakefile
98
132
  - bin/console
99
133
  - bin/setup
134
+ - docs/provider-neutral-api-execution-contract.md
100
135
  - json-2.18.1.gem
101
136
  - lib/agent-harness.rb
102
137
  - lib/agent_harness.rb
@@ -106,6 +141,9 @@ files:
106
141
  - lib/agent_harness/conversation.rb
107
142
  - lib/agent_harness/dependency_updater.rb
108
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
109
147
  - lib/agent_harness/error_taxonomy.rb
110
148
  - lib/agent_harness/errors.rb
111
149
  - lib/agent_harness/execution_preparation.rb