agent-harness 0.37.5 → 0.39.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: 689dc0db60fba2154368f84887018123219d2933eabf037b8592bd0c4ab4c6a6
4
- data.tar.gz: 1a66d12fde96441a8220656cb49ef1b742372c12de8873f6b5676a6f8540dc6e
3
+ metadata.gz: 6b6b9b84a3074d82a44dd35a6b377628c64cfa01958ca5c41ec910b694375fcf
4
+ data.tar.gz: 8069427d9a79624deba026984490a5f25cf97df7364c6d78b44c2a43aef773c6
5
5
  SHA512:
6
- metadata.gz: c966600212a4920c975efa9f7155b64c31aa8129a524eceedf993acbc0d3d099c3ef59cddaf1f23d51edafdad9b9e88a38e5fb18e8bcf36322beecb4f37eb8d7
7
- data.tar.gz: 05cd073a1878124085ced27da8b297dc67e84ccc900ef9e38e1d2893b1de4f3b72a4c11e516b3575d9b6bcf66eaf0c1976bf8b8ee5ee94fc54c17e0f8f16e42e
6
+ metadata.gz: cad34514f544dcf13a470afbb7cbdde82269246ee16d97319e3e6f33afd2e663b0625fd2f088bc4c68ef491676ac3c5cfc80e6bc3ba5a030fbda1abbe9a04cf7
7
+ data.tar.gz: 48fd85599fca1ae94fe13e433d4a69d1ab5bd8ce4c9c8feab6388c79c2d48c5314467cba8c4fcb277662c1aea20f53206b35fef6cad0a82ef82618249a80a883
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.37.5"
2
+ ".": "0.39.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.39.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.38.0...agent-harness/v0.39.0) (2026-09-25)
9
+
10
+
11
+ ### Features
12
+
13
+ * 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))
14
+
15
+ ## [0.38.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.37.5...agent-harness/v0.38.0) (2026-09-24)
16
+
17
+
18
+ ### Features
19
+
20
+ * **codex:** expose account-local discovery for container recovery ([#428](https://github.com/viamin/agent-harness/issues/428)) ([a67c11f](https://github.com/viamin/agent-harness/commit/a67c11f46aaf99cd440cbb69edbd3272f3ed9312))
21
+
8
22
  ## [0.37.5](https://github.com/viamin/agent-harness/compare/agent-harness/v0.37.4...agent-harness/v0.37.5) (2026-09-21)
9
23
 
10
24
 
data/README.md CHANGED
@@ -419,6 +419,24 @@ result.fallback_model_id # => "gpt-5.2-codex"
419
419
  result.source # => :static_contract
420
420
  ```
421
421
 
422
+ For account-local Codex discovery, use the same executor and credential
423
+ environment as execution:
424
+
425
+ ```ruby
426
+ provider = AgentHarness::Providers::Codex.new(executor: container_executor)
427
+ discovery = provider.discover_available_models(env: subscription_env, timeout: 15)
428
+ discovery.models # normalized visible entries from model/list
429
+ discovery.recommended_model_id # provider default, not proof of execution
430
+ ```
431
+
432
+ This method performs a fresh, paginated app-server exchange within the supplied
433
+ execution context. It supports execute-only container transports using Node
434
+ (already required by the Codex npm installation). It never changes auth mode or
435
+ selects a model on the caller's behalf. Apply project policy, verify the chosen
436
+ model with `smoke_test`, and persist successful evidence in the calling system.
437
+ `classify_model_rejection_from_result(stdout:, stderr:)` excludes ordinary
438
+ assistant/tool stdout from rejection classification.
439
+
422
440
  Outcomes follow three explicit shapes:
423
441
 
424
442
  - **Supported** — `result.supported?` is `true`. The runner contract
@@ -720,6 +738,12 @@ Health checks run five steps per provider: registration, CLI availability, authe
720
738
 
721
739
  ## Development
722
740
 
741
+ The proposed provider-neutral API execution boundary for embeddings, chat,
742
+ structured output, usage, and optional persistence is documented in the
743
+ [Provider-Neutral API Execution Contract](docs/provider-neutral-api-execution-contract.md).
744
+ It is a docs-only design boundary; capability support requires the release
745
+ evidence described there.
746
+
723
747
  ```bash
724
748
  # Install dependencies
725
749
  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.
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "net/http"
5
5
  require "uri"
6
+ require_relative "codex_model_discovery"
6
7
 
7
8
  module AgentHarness
8
9
  module Providers
@@ -12,6 +13,7 @@ module AgentHarness
12
13
  class Codex < Base
13
14
  include RateLimitResetParsing
14
15
  include McpConfigFileSupport
16
+ include CodexModelDiscovery
15
17
 
16
18
  StreamingEvent = Struct.new(
17
19
  :type, :turn, :tokens, :error_message, :tool_name, :raw_event
@@ -546,6 +548,24 @@ module AgentHarness
546
548
  parser_instance.send(:classify_model_rejection, output, configured_model: configured_model)
547
549
  end
548
550
 
551
+ # Successful assistant/tool output can quote the exact rejection text.
552
+ # Only CLI stderr and explicit JSONL error envelopes are evidence.
553
+ def classify_model_rejection_from_result(stdout:, stderr:, configured_model: nil)
554
+ texts = stdout.to_s.each_line.filter_map do |line|
555
+ event = parse_stdout_jsonl_event(line.strip)
556
+ next unless event.is_a?(Hash)
557
+
558
+ event = unwrap_classification_event(event)
559
+ extract_jsonl_error_text(event) if event.is_a?(Hash)
560
+ end
561
+ texts << stderr.to_s
562
+ texts.each do |text|
563
+ rejection = classify_model_rejection(text, configured_model: configured_model)
564
+ return rejection if rejection
565
+ end
566
+ nil
567
+ end
568
+
549
569
  private
550
570
 
551
571
  def classify_stdout_chunk(text, buffer)
@@ -1095,11 +1115,15 @@ module AgentHarness
1095
1115
  end
1096
1116
 
1097
1117
  def fetch_model_list(rejected_model_id:, allowed_model_ids:, env:, timeout:)
1098
- result = @executor.execute_interactive(
1099
- [self.class.binary_name, "app-server", "--listen", "stdio://"],
1100
- timeout: timeout,
1101
- env: env
1102
- ) { |stdin, stdout| exchange_model_list_requests(stdin, stdout) }
1118
+ result = if @executor.respond_to?(:execute_interactive)
1119
+ @executor.execute_interactive(
1120
+ [self.class.binary_name, "app-server", "--listen", "stdio://"],
1121
+ timeout: timeout,
1122
+ env: env
1123
+ ) { |stdin, stdout| exchange_model_list_requests(stdin, stdout) }
1124
+ else
1125
+ execute_model_discovery(env: env, timeout: timeout)
1126
+ end
1103
1127
  return unavailable_model_discovery(:app_server_failed, stderr: result.stderr) unless result.success?
1104
1128
 
1105
1129
  response = parse_app_server_response(result.stdout, 2)
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Providers
5
+ # Runs the interactive protocol beside the CLI, including for executors that
6
+ # only expose execute (Docker/remote transports). Codex's npm installation
7
+ # already requires Node. No shell, host credentials, or host CLI is used.
8
+ module CodexModelDiscovery
9
+ SCRIPT = <<~'JS'
10
+ const { spawn } = require('node:child_process');
11
+ const child = spawn(process.argv[1], ['app-server', '--listen', 'stdio://'], {stdio: ['pipe', 'pipe', 'pipe']});
12
+ let buffer = '', bytes = 0, page = 0, done = false;
13
+ const models = [], cursors = new Set();
14
+ const send = value => child.stdin.write(JSON.stringify(value) + '\n');
15
+ const finish = (code, value) => {
16
+ if (done) return;
17
+ done = true;
18
+ clearTimeout(timer);
19
+ if (value) process.stdout.write(JSON.stringify(value) + '\n');
20
+ process.exitCode = code;
21
+ child.stdin.end();
22
+ child.kill('SIGTERM');
23
+ setTimeout(() => child.kill('SIGKILL'), 250).unref();
24
+ };
25
+ const timer = setTimeout(() => finish(1), Number(process.argv[2]));
26
+ child.on('error', () => finish(1));
27
+ child.stdin.on('error', () => finish(1));
28
+ child.stderr.on('data', () => {});
29
+ child.on('close', () => { if (!done) finish(1); });
30
+ child.stdout.on('data', chunk => {
31
+ bytes += chunk.length;
32
+ if (bytes > 1048576) return finish(1);
33
+ buffer += chunk.toString();
34
+ let end;
35
+ while (!done && (end = buffer.indexOf('\n')) >= 0) {
36
+ const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
37
+ let msg; try { msg = JSON.parse(line); } catch { continue; }
38
+ if (msg.id !== 1 && msg.id !== 2) continue;
39
+ if (msg.error) return finish(0, {id: 2, error: msg.error});
40
+ if (msg.id === 1) {
41
+ send({method: 'initialized', params: {}});
42
+ send({id: 2, method: 'model/list', params: {limit: 100}});
43
+ } else {
44
+ if (!Array.isArray(msg.result?.data)) return finish(1);
45
+ models.push(...msg.result.data);
46
+ const cursor = msg.result.nextCursor;
47
+ if (!cursor) return finish(0, {id: 2, result: {data: models}});
48
+ if (++page >= 10 || cursors.has(cursor)) return finish(1);
49
+ cursors.add(cursor);
50
+ send({id: 2, method: 'model/list', params: {limit: 100, cursor}});
51
+ }
52
+ }
53
+ });
54
+ send({id: 1, method: 'initialize', params: {clientInfo: {name: 'agent-harness', version: '1.0.0'}}});
55
+ JS
56
+
57
+ # Fresh account-local discovery. The caller owns selection policy and must
58
+ # still smoke-test its selected model; model/list is not execution proof.
59
+ def discover_available_models(env:, timeout: 15)
60
+ result = execute_model_discovery(env: env, timeout: timeout)
61
+ return unavailable_model_discovery(:app_server_failed) unless result.success?
62
+
63
+ response = parse_app_server_response(result.stdout, 2)
64
+ return unavailable_model_discovery(:model_list_missing_response) unless response
65
+ return unavailable_model_discovery(:model_list_error) if response["error"]
66
+
67
+ entries = Array(response.dig("result", "data")).filter_map { |entry| normalize_model_entry(entry) }
68
+ .reject { |entry| entry[:hidden] }.uniq { |entry| entry[:id] }
69
+ return unavailable_model_discovery(:no_compatible_model) if entries.empty?
70
+
71
+ preferred = entries.find { |entry| entry[:is_default] } || entries.first
72
+ self.class::ModelDiscovery.new(status: :available, models: entries,
73
+ recommended_model_id: preferred[:id], source: :codex_app_server_model_list)
74
+ rescue TimeoutError
75
+ unavailable_model_discovery(:app_server_timeout)
76
+ end
77
+
78
+ private
79
+
80
+ def execute_model_discovery(env:, timeout:)
81
+ @executor.execute(
82
+ ["node", "-e", SCRIPT, self.class.binary_name, [(timeout * 1000).to_i - 500, 100].max.to_s],
83
+ env: env, timeout: timeout
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.37.5"
4
+ VERSION = "0.39.0"
5
5
  end
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.37.5
4
+ version: 0.39.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -97,6 +97,7 @@ files:
97
97
  - Rakefile
98
98
  - bin/console
99
99
  - bin/setup
100
+ - docs/provider-neutral-api-execution-contract.md
100
101
  - json-2.18.1.gem
101
102
  - lib/agent-harness.rb
102
103
  - lib/agent_harness.rb
@@ -128,6 +129,7 @@ files:
128
129
  - lib/agent_harness/providers/anthropic.rb
129
130
  - lib/agent_harness/providers/base.rb
130
131
  - lib/agent_harness/providers/codex.rb
132
+ - lib/agent_harness/providers/codex_model_discovery.rb
131
133
  - lib/agent_harness/providers/cursor.rb
132
134
  - lib/agent_harness/providers/gemini.rb
133
135
  - lib/agent_harness/providers/github_copilot.rb