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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +71 -0
- data/docs/provider-neutral-api-execution-contract.md +532 -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 +39 -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,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
|
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
|
|
@@ -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
|