agent-harness 0.41.0 → 0.43.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: c4406422d8b90ec6406bb4cdcc6e1d7496b708f43aad306d258c2570ed4f710e
4
- data.tar.gz: cd23fe2f7d4b05e2ac18ac9aa2a631a0b292bc2703ae092f4f55a1ca6f3febbb
3
+ metadata.gz: 40d84099230c75237b23dca08330a46b739029768ce345b57cb232ba0e66a935
4
+ data.tar.gz: 47d36a8e160bcd29f9d50f6eabc1d0ded6795d6e05f2e45e93038d1987cd352c
5
5
  SHA512:
6
- metadata.gz: efb5e61347d70b28e03bb0cd2843429928843147bf8f196d1292af97be97c8ec67ca4ef81e6f169562d1fac5e88e277b06d07a7cd44bf3d85a772effb2c46051
7
- data.tar.gz: 7c5fb37c7e14edbc3da68f74dc3f1a6d10e04630e70509a79bd04b9ebbfde2397d6a4753e288dd33d962efae79003728d30cdb0ae113d1bd4864de5995daa112
6
+ metadata.gz: f059d918fed14691fe444dba2e5e93aaeb2cdb185785a580b8a7341db8198616a0a830fd43461b4dfceffc9258f71ce25a8cbb69351747af46980ba0bd783757
7
+ data.tar.gz: 2f09db993da48a0ca0ed852eee9653898f474e7c61a6e0603dde3588457d226c8f51de71a8b322e7eba46b7e64203cc61a7607aa31764ed63188ef8b38f7c1e5
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.41.0"
2
+ ".": "0.43.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -2,9 +2,24 @@
2
2
 
3
3
  ### Features
4
4
 
5
+ * expose normalized API chat transport and schema-constrained responses with preserved JSON text, locally validated parsed values, and explicit refusal, truncation, invalid JSON, schema mismatch, and unsupported-mode outcomes; this combined delivery supersedes the separate chat-transport work item ([#433](https://github.com/viamin/agent-harness/issues/433), [#434](https://github.com/viamin/agent-harness/issues/434)).
5
6
  * 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
7
  * **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
8
 
9
+ ## [0.43.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.42.0...agent-harness/v0.43.0) (2026-09-25)
10
+
11
+
12
+ ### Features
13
+
14
+ * Expose Schema-Constrained Parsed Responses (RDR-072) ([#442](https://github.com/viamin/agent-harness/issues/442)) ([3d05cfa](https://github.com/viamin/agent-harness/commit/3d05cfac68624c218eda5f6f9871a3a4d1044ec0))
15
+
16
+ ## [0.42.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.41.0...agent-harness/v0.42.0) (2026-09-25)
17
+
18
+
19
+ ### Features
20
+
21
+ * Expose Attempt-Level Usage and Cost (RDR-072) ([#443](https://github.com/viamin/agent-harness/issues/443)) ([818f8ca](https://github.com/viamin/agent-harness/commit/818f8ca0f873f002fc4d26b2a864238ae41fa866))
22
+
8
23
  ## [0.41.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.40.0...agent-harness/v0.41.0) (2026-09-25)
9
24
 
10
25
 
@@ -8,8 +8,8 @@ usage, and optional conversation-persistence work.
8
8
  ## Status and rollout boundary
9
9
 
10
10
  RDR-072's rollout guard was **docs-only** for the design phase. The normalized
11
- chat capability described in "Shipped normalized chat surface" below is now
12
- implemented. Other capabilities remain design contracts and each still needs
11
+ chat, attempt-accounting, schema, and embedding capabilities described below
12
+ are now implemented. Other capabilities remain design contracts and each still needs
13
13
  its own failing-first contract tests, implementation, release evidence, and
14
14
  downstream adoption evidence before a caller enables it.
15
15
 
@@ -70,11 +70,13 @@ Responses API must select `:chat_completions`; the transport never probes and
70
70
  silently switches protocols. Only `authentication_mode: :api_key` is currently
71
71
  supported.
72
72
 
73
- Credentials, endpoint, custom headers, timeout, and RubyLLM configuration are
74
- isolated with a request-local `RubyLLM::Context`. RubyLLM middleware retries
75
- are disabled; `retry.max_attempts` is the total physical-attempt limit owned by
76
- the harness. Authentication headers cannot be overridden by custom headers.
77
- `max_output_tokens` is forwarded without changing it.
73
+ Credentials, endpoint, custom headers, read timeout, and RubyLLM configuration
74
+ are isolated with a request-local `RubyLLM::Context`. Only
75
+ `timeout.read_seconds` is supported; request-local connection timeouts are not.
76
+ RubyLLM middleware retries are disabled; `retry.max_attempts` is the total
77
+ physical-attempt limit owned by the harness. Authentication headers cannot be
78
+ overridden by custom headers. `max_output_tokens` is forwarded without changing
79
+ it.
78
80
 
79
81
  Unknown model IDs are allowed only because a complete provider and protocol
80
82
  are explicit in every candidate (`assume_model_exists: true` in the RubyLLM
@@ -84,6 +86,41 @@ endpoints retain the selected provider's wire protocol and authentication
84
86
  shape. Custom provider types, authentication modes, media content, and
85
87
  automatic protocol discovery are not supported by this capability.
86
88
 
89
+ ## Shipped schema-constrained response surface
90
+
91
+ The same transport accepts `operation: :schema` with a JSON Schema and an
92
+ optional name:
93
+
94
+ ```ruby
95
+ result = transport.call(request.merge(
96
+ operation: :schema,
97
+ schema_name: "person",
98
+ schema: {
99
+ type: "object",
100
+ properties: {name: {type: "string"}, age: {type: "integer"}},
101
+ required: %w[name age],
102
+ additionalProperties: false
103
+ }
104
+ ))
105
+
106
+ result[:content] # => '{"name":"Ada","age":37}'
107
+ result[:parsed] # => {"name" => "Ada", "age" => 37}
108
+ ```
109
+
110
+ Schema operations use provider-native JSON Schema output and the same verified
111
+ Anthropic Messages, OpenAI Responses, and OpenAI Chat Completions scopes as
112
+ normalized chat. Provider adapters infer strictness from the schema; callers
113
+ using a `{schema:, strict:}` envelope can explicitly select strictness.
114
+ `schema_mode: :json_schema` is the only supported mode. JSON-only mode returns
115
+ `unsupported/structured_output_not_supported`.
116
+
117
+ The harness parses the exact provider text and validates it locally. It does
118
+ not remove Markdown fences or repair malformed JSON. Invalid JSON, schema
119
+ mismatch, refusal, and output-limit truncation return non-retryable
120
+ `invalid_response` outcomes with codes `invalid_json`, `invalid_schema`,
121
+ `refusal`, and `truncated_output`. They retain the original `content` and leave
122
+ `parsed` as `nil`.
123
+
87
124
  ## Ownership boundary
88
125
 
89
126
  AgentHarness owns protocol translation and one bounded provider request:
@@ -139,7 +176,7 @@ request = {
139
176
  }
140
177
  ],
141
178
  fallback: {on_error_categories: [:transient]},
142
- timeout: {connect_seconds: 5, read_seconds: 60},
179
+ timeout: {read_seconds: 60},
143
180
  retry: {max_attempts: 3, base_delay_seconds: 0.25, max_delay_seconds: 2},
144
181
  cancellation: cancellation_token,
145
182
  metadata: {tenant_id: "tenant-123", workflow_id: "workflow-456"}
@@ -160,9 +197,11 @@ part of a serializable request document.
160
197
  gets a distinct `attempt_id`. Redelivering an already reported attempt retains
161
198
  its `attempt_id`; initiating another outbound request does not.
162
199
 
163
- Credentials, endpoint, headers, timeouts, retry limits, and cancellation are
164
- request-local. Implementations MUST prevent concurrent requests from observing
165
- one another's credentials or headers. They MUST reject reserved header
200
+ Credentials, endpoint, headers, the read timeout, retry limits, and cancellation
201
+ are request-local. Only `timeout.read_seconds` is supported; supplying a
202
+ connection timeout returns `unsupported/unsupported_capability` before any
203
+ provider request. Implementations MUST prevent concurrent requests from
204
+ observing one another's credentials or headers. They MUST reject reserved header
166
205
  overrides that would conflict with the selected protocol's authentication.
167
206
  Logs and errors MUST NOT contain credentials, authorization headers, message
168
207
  bodies, tool arguments, or full provider responses.
@@ -336,6 +375,23 @@ observer so durable accounting can persist an attempt even when no message is
336
375
  created. Callers deduplicate on `attempt_id`. Cost identifies its source as
337
376
  provider-reported or harness-estimated; unknown cost remains `nil`.
338
377
 
378
+ The observer receives `attempt_completed` after every physical request, with
379
+ the same report later returned in `result[:attempts]`. Delivery is at least
380
+ once across process recovery: consumers MUST enforce a unique key on
381
+ `attempt_id` and treat a repeated report as an idempotent upsert, not another
382
+ charge. `AttemptReport.from_h` restores JSON-decoded reports. Stored cost
383
+ includes USD component amounts, `source` (`provider_reported` or `estimated`),
384
+ and `priced_at`; restoration never consults current prices. Missing counts and
385
+ prices remain absent/`nil`, while a reported zero remains zero. Usage may also
386
+ include `cache_read_tokens`, `cache_write_tokens`, and `thinking_tokens`.
387
+
388
+ Attempt events contain only identifiers, provider/model, outcome, normalized
389
+ usage/cost, timestamps, and sanitized classified errors. They do not include
390
+ prompts, messages, request headers, endpoints, or credentials. RubyLLM API chat
391
+ requests are in this ledger. Existing CLI providers, `TextTransport`,
392
+ `OpenAICompatibleTransport`, token trackers, embeddings, and other
393
+ non-`Api::ChatTransport` paths remain outside it.
394
+
339
395
  Only errors classified `transient` are eligible for bounded request retry:
340
396
  connection failure, timeout before a partial stream, rate limit, server error,
341
397
  service unavailable, and overload. Authentication, authorization, billing,
@@ -487,7 +543,7 @@ records.
487
543
  | Contract area | RubyLLM 2.0 mapping | Decision or gap |
488
544
  | --- | --- | --- |
489
545
  | Chat/protocols | `RubyLLM.chat`, messages, tools, stream callbacks | Candidate adapter; normalize all values and errors |
490
- | Schema | `with_schema`, `response.parsed`, model capability registry | Candidate; distinguish enforced schema from JSON mode |
546
+ | Schema | `with_schema`; harness JSON parsing and validation | Implemented for the verified chat scopes; JSON-only mode stays distinct |
491
547
  | Embeddings | `RubyLLM.embed` and normalized vectors/usage | Candidate first capability; persistence remains in Paid |
492
548
  | Custom headers | `with_headers` | Candidate; contract-test merging and secret redaction |
493
549
  | Endpoint/credentials | provider configuration | Global mutable configuration is unsuitable; require request-local isolation or an upstream-supported client boundary |
@@ -569,11 +625,47 @@ types stay behind the harness boundary.
569
625
 
570
626
  ## Compatibility and release evidence
571
627
 
628
+ ### Attempt-accounting capability evidence
629
+
630
+ - Release: pending the first published version containing issue #435; a Git
631
+ branch or tag alone is not downstream adoption evidence.
632
+ - Scope: normalized chat through `Api::ChatTransport` for Anthropic Messages
633
+ and OpenAI Responses/Chat Completions (including compatible endpoints), with
634
+ request-local API-key authentication. It is stacked on the normalized chat
635
+ capability from #433.
636
+ - Verification: the API contract specs cover request-local credentials,
637
+ endpoint/header isolation, error classification, bounded non-nested retry,
638
+ fallback, cancellation, partial usage, cache usage, repeated identity,
639
+ observer redaction, and JSON reload with preserved pricing. The full upstream
640
+ suite and lint run on the repository's supported Ruby environment.
641
+ - Migration: no Rails tables or migrations are loaded or required. Paid and
642
+ agent-image consumer versions remain unverified and MUST NOT adopt this
643
+ capability until their integration suites record the exact released gem and
644
+ image versions.
645
+ - Retained paths: CLI/subscription providers, legacy HTTP transports,
646
+ embeddings, and token trackers remain outside this ledger and require
647
+ separate migration issues.
648
+
649
+ ### Schema capability release evidence
650
+
651
+ - Publication: unreleased; record the first installable version before
652
+ downstream adoption.
653
+ - Verified scopes: `:schema` with Anthropic Messages, OpenAI Responses, and
654
+ OpenAI Chat Completions using API-key authentication, including compatible
655
+ endpoints that explicitly select Chat Completions.
656
+ - Contract coverage: valid and required-field schemas, classified failures,
657
+ bounded retries, cancellation, refusal, truncation, malformed JSON, and
658
+ schema mismatch.
659
+ - Retained paths: CLI and subscription execution remain on existing provider
660
+ interfaces. JSON-only mode and model-specific capability discovery are not
661
+ migrated.
662
+
572
663
  AgentHarness currently supports Ruby 3.2 and later and must remain usable as a
573
- plain Ruby gem. RubyLLM 2.0.0 itself supports Ruby 3.1.3 and later, but adding it
574
- would introduce Faraday, event-stream parsing, Schematist, Marcel, and Zeitwerk
575
- runtime dependencies. A capability issue must measure and publish the resolved
576
- dependency set and test the harness minimum Ruby version before adoption.
664
+ plain Ruby gem. RubyLLM 2.0.0 itself supports Ruby 3.1.3 and later and adds
665
+ Faraday, event-stream parsing, Schematist, Marcel, and Zeitwerk runtime
666
+ dependencies. Local response validation adds `json_schemer` and its bounded
667
+ dependency set. A capability issue must test the harness minimum Ruby version
668
+ before adoption.
577
669
 
578
670
  Rails and Active Record remain optional. Requiring `agent_harness` in a process
579
671
  without Rails MUST NOT load Active Record, connect to a database, or require
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module AgentHarness
6
+ module Api
7
+ # Serializable accounting facts for one physical provider request.
8
+ class AttemptReport
9
+ STATUSES = %i[succeeded failed cancelled partial].freeze
10
+ USAGE_KEYS = %i[input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens total_tokens].freeze
11
+ COST_KEYS = %i[input output cache_read cache_write thinking total].freeze
12
+
13
+ attr_reader :attributes
14
+
15
+ def self.from_h(attributes)
16
+ new(**symbolize(attributes))
17
+ end
18
+
19
+ def self.symbolize(value)
20
+ return value.to_h { |key, child| [key.to_sym, symbolize(child)] } if value.is_a?(Hash)
21
+ return value.map { |child| symbolize(child) } if value.is_a?(Array)
22
+
23
+ value
24
+ end
25
+ private_class_method :symbolize
26
+
27
+ def initialize(attempt_id:, request_id:, number:, provider:, model:, status:, started_at:, finished_at:,
28
+ usage: nil, cost: nil, provider_reported: false, error: nil)
29
+ @attributes = {
30
+ attempt_id: attempt_id.to_s, request_id: request_id.to_s, number: number,
31
+ provider: provider.to_sym, model: model&.to_s, status: status.to_sym,
32
+ started_at: timestamp(started_at), finished_at: timestamp(finished_at),
33
+ usage: normalize_usage(usage), cost: normalize_cost(cost),
34
+ provider_reported: provider_reported == true, error: error
35
+ }
36
+ validate!
37
+ deep_freeze(@attributes)
38
+ end
39
+
40
+ def to_h
41
+ deep_dup(attributes)
42
+ end
43
+
44
+ private
45
+
46
+ def timestamp(value)
47
+ value.respond_to?(:iso8601) ? value.iso8601(6) : Time.iso8601(value.to_s).utc.iso8601(6)
48
+ end
49
+
50
+ def normalize_usage(usage)
51
+ return unless usage
52
+
53
+ normalized = self.class.send(:symbolize, usage)
54
+ USAGE_KEYS.each_with_object({}) do |key, result|
55
+ result[key] = normalized[key] if normalized.key?(key)
56
+ end
57
+ end
58
+
59
+ def normalize_cost(cost)
60
+ return unless cost
61
+
62
+ normalized = self.class.send(:symbolize, cost)
63
+ amounts = COST_KEYS.to_h { |key| [key, normalized[key]] }
64
+ amounts.merge(currency: normalized.fetch(:currency, "USD"), source: normalized.fetch(:source).to_sym,
65
+ priced_at: timestamp(normalized.fetch(:priced_at)))
66
+ end
67
+
68
+ def validate!
69
+ raise ArgumentError, "attempt_id is required" if attributes[:attempt_id].empty?
70
+ raise ArgumentError, "request_id is required" if attributes[:request_id].empty?
71
+ raise ArgumentError, "number must be a positive integer" unless attributes[:number].is_a?(Integer) && attributes[:number].positive?
72
+ raise ArgumentError, "unknown attempt status" unless STATUSES.include?(attributes[:status])
73
+ end
74
+
75
+ def deep_freeze(value)
76
+ value.each_value { |child| deep_freeze(child) } if value.is_a?(Hash)
77
+ value.each { |child| deep_freeze(child) } if value.is_a?(Array)
78
+ value.freeze
79
+ end
80
+
81
+ def deep_dup(value)
82
+ return value.to_h { |key, child| [key, deep_dup(child)] } if value.is_a?(Hash)
83
+ return value.map { |child| deep_dup(child) } if value.is_a?(Array)
84
+
85
+ value
86
+ end
87
+ end
88
+ end
89
+ end
@@ -2,7 +2,9 @@
2
2
 
3
3
  require "securerandom"
4
4
  require "ruby_llm"
5
+ require_relative "attempt_report"
5
6
  require_relative "ruby_llm_chat_adapter"
7
+ require_relative "schema_response"
6
8
 
7
9
  module AgentHarness
8
10
  module Api
@@ -53,6 +55,7 @@ module AgentHarness
53
55
  end
54
56
 
55
57
  def call
58
+ return unsupported_schema_result unless schema_mode_supported?
56
59
  return cancelled_result unless active?
57
60
 
58
61
  candidates.each_with_index do |candidate, candidate_index|
@@ -85,6 +88,19 @@ module AgentHarness
85
88
  end
86
89
 
87
90
  def perform_attempt(candidate)
91
+ accounting = nil
92
+ provider_usage = -> { !streamed_usage.nil? }
93
+ prepared_chat = @adapter.prepare(
94
+ candidate: candidate,
95
+ messages: request[:messages],
96
+ tools: request[:tools] || [],
97
+ max_output_tokens: request[:max_output_tokens],
98
+ temperature: request[:temperature],
99
+ timeout: request[:timeout],
100
+ schema: schema_payload,
101
+ on_accounting: ->(facts) { accounting = facts },
102
+ provider_usage: provider_usage
103
+ )
88
104
  attempt_id = @id_generator.call
89
105
  started_at = Time.now.utc
90
106
  emitted = false
@@ -99,7 +115,10 @@ module AgentHarness
99
115
  temperature: request[:temperature],
100
116
  stream: request[:stream] == true,
101
117
  timeout: request[:timeout],
102
- cancellation: request[:cancellation]
118
+ cancellation: request[:cancellation],
119
+ schema: schema_payload,
120
+ on_accounting: ->(facts) { accounting = facts },
121
+ prepared_chat: prepared_chat
103
122
  ) do |event|
104
123
  event = normalize_stream_event(event)
105
124
  emitted = true if output_event?(event)
@@ -109,31 +128,47 @@ module AgentHarness
109
128
  end
110
129
  raise RubyLLM::CancelledError unless active?
111
130
 
112
- success(candidate, attempt_id, started_at, adapter_result)
131
+ success(candidate, attempt_id, started_at, adapter_result, accounting)
113
132
  rescue ObserverError
114
133
  raise
115
134
  rescue => error
116
- failure(candidate, attempt_id, started_at, classify(error), partial: emitted, usage: streamed_usage)
135
+ return failed_result(classify(error), candidate) unless attempt_id
136
+
137
+ failure(candidate, attempt_id, started_at, classify(error), partial: emitted,
138
+ accounting: accounting || {
139
+ usage: streamed_usage, provider_reported: !streamed_usage.nil?
140
+ })
117
141
  end
118
142
 
119
- def success(candidate, attempt_id, started_at, adapter_result)
120
- attempts << attempt_report(candidate, attempt_id, started_at, :succeeded, usage: adapter_result[:usage])
143
+ def success(candidate, attempt_id, started_at, adapter_result, accounting)
144
+ accounting ||= {usage: adapter_result[:usage], provider_reported: !adapter_result[:usage].nil?}
145
+ schema_result = normalize_schema_response(adapter_result)
146
+ status = schema_result[:error] ? :failed : :succeeded
147
+ append_attempt(candidate, attempt_id, started_at, status, error: schema_result[:error], **accounting)
121
148
  result = base_result(candidate).merge(
122
- status: :succeeded,
123
- content: adapter_result[:content] || "",
149
+ status: status,
150
+ content: schema_result[:content],
151
+ parsed: schema_result[:parsed],
124
152
  tool_calls: normalize_tool_calls(adapter_result[:tool_calls]),
125
153
  finish_reason: adapter_result[:finish_reason],
126
154
  usage: aggregate_usage,
127
- error: nil
155
+ error: schema_result[:error]
128
156
  )
129
- emit(:response_completed, attempt_id:, result: result)
157
+ event = (status == :succeeded) ? :response_completed : :response_failed
158
+ emit(event, attempt_id:, result: result)
130
159
  result
131
160
  end
132
161
 
133
- def failure(candidate, attempt_id, started_at, error, partial:, usage:)
162
+ def normalize_schema_response(adapter_result)
163
+ return {content: adapter_result[:content] || "", parsed: nil, error: nil} unless schema_operation?
164
+
165
+ SchemaResponse.new(schema_definition).call(adapter_result)
166
+ end
167
+
168
+ def failure(candidate, attempt_id, started_at, error, partial:, accounting:)
134
169
  status = failure_status(error, partial)
135
170
  error = error.merge(retryable: false) if partial
136
- attempts << attempt_report(candidate, attempt_id, started_at, status, error: error, usage: usage)
171
+ append_attempt(candidate, attempt_id, started_at, status, error: error, **accounting)
137
172
  result = base_result(candidate).merge(
138
173
  status: status,
139
174
  content: partial ? @partial_content.dup : "",
@@ -178,8 +213,9 @@ module AgentHarness
178
213
  def backoff
179
214
  exponent = [attempts.length - 1, 0].max
180
215
  delay = retry_config[:base_delay_seconds] * (2**exponent)
216
+ delay = [delay, @last_error[:retry_after_seconds].to_f].max
181
217
  cap = retry_config[:max_delay_seconds]
182
- remaining = cap&.positive? ? [delay, cap].min : delay
218
+ remaining = cap.nil? ? delay : [delay, cap].min
183
219
  while remaining.positive? && active?
184
220
  interval = [remaining, 0.05].min
185
221
  @sleeper.call(interval)
@@ -227,7 +263,8 @@ module AgentHarness
227
263
 
228
264
  def normalize_tool_calls(tool_calls)
229
265
  Array(tool_calls).map do |call|
230
- call.merge(id: tool_id(call[:provider_id]), status: :completed)
266
+ id = call[:provider_id].nil? ? @id_generator.call : tool_id(call[:provider_id])
267
+ call.merge(id: id, status: :completed)
231
268
  end
232
269
  end
233
270
 
@@ -265,8 +302,10 @@ module AgentHarness
265
302
  usage: aggregate_usage, error: error)
266
303
  end
267
304
 
268
- def attempt_report(candidate, attempt_id, started_at, status, error: nil, usage: nil)
269
- {
305
+ def append_attempt(candidate, attempt_id, started_at, status, error: nil, usage: nil, cost: nil,
306
+ provider_reported: false)
307
+ cost = cost.merge(priced_at: Time.now.utc) if cost
308
+ report = AttemptReport.new(
270
309
  attempt_id: attempt_id,
271
310
  request_id: request[:request_id],
272
311
  number: attempts.length + 1,
@@ -276,20 +315,22 @@ module AgentHarness
276
315
  started_at: started_at.iso8601(6),
277
316
  finished_at: Time.now.utc.iso8601(6),
278
317
  usage: usage,
279
- cost: nil,
280
- provider_reported: false,
318
+ cost: cost,
319
+ provider_reported: provider_reported,
281
320
  error: error
282
- }
321
+ ).attributes
322
+ attempts << report
323
+ emit(:attempt_completed, attempt_id:, attempt: report)
283
324
  end
284
325
 
285
326
  def aggregate_usage
286
327
  reports = attempts.filter_map { |attempt| attempt[:usage] }
287
328
  return if reports.empty?
288
329
 
289
- %i[input_tokens output_tokens total_tokens].to_h do |key|
330
+ %i[input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens total_tokens].to_h do |key|
290
331
  values = reports.filter_map { |usage| usage[key] }
291
332
  [key, values.empty? ? nil : values.sum]
292
- end
333
+ end.compact
293
334
  end
294
335
 
295
336
  def classify(error)
@@ -316,14 +357,48 @@ module AgentHarness
316
357
  end
317
358
 
318
359
  def validate!
319
- raise ArgumentError, "operation must be :chat" unless request[:operation]&.to_sym == :chat
360
+ unless %i[chat schema].include?(request[:operation]&.to_sym)
361
+ raise ArgumentError, "operation must be :chat or :schema"
362
+ end
320
363
  raise ArgumentError, "request_id is required" if request[:request_id].to_s.empty?
321
364
  raise ArgumentError, "candidates must not be empty" if !request[:candidates].is_a?(Array) || request[:candidates].empty?
322
365
  raise ArgumentError, "messages must be an array" unless request[:messages].is_a?(Array)
366
+ raise ArgumentError, "schema is required for a schema operation" if schema_operation? && !request[:schema].is_a?(Hash)
323
367
  validate_attempt_limit!
324
368
  candidates.each { |candidate| validate_candidate!(candidate) }
325
369
  end
326
370
 
371
+ def schema_operation?
372
+ request[:operation].to_sym == :schema
373
+ end
374
+
375
+ def schema_mode_supported?
376
+ !schema_operation? || !request[:schema_mode] || request[:schema_mode].to_sym == :json_schema
377
+ end
378
+
379
+ def schema_payload
380
+ return unless schema_operation?
381
+
382
+ raw = request[:schema]
383
+ return raw.merge(name: request[:schema_name] || raw[:name]) if raw[:schema]
384
+
385
+ {name: request[:schema_name] || raw[:title] || "response", schema: raw}
386
+ end
387
+
388
+ def schema_definition
389
+ schema_payload.fetch(:schema)
390
+ end
391
+
392
+ def unsupported_schema_result
393
+ error = {
394
+ category: :unsupported,
395
+ code: :structured_output_not_supported,
396
+ retryable: false,
397
+ message: "Schema request failed (unsupported/structured_output_not_supported)"
398
+ }
399
+ failed_result(error, candidates.first)
400
+ end
401
+
327
402
  def validate_attempt_limit!
328
403
  limit = retry_config[:max_attempts]
329
404
  raise ArgumentError, "retry.max_attempts must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
@@ -402,10 +477,23 @@ module AgentHarness
402
477
  end
403
478
 
404
479
  def self.payload(error, category, code, retryable)
405
- {category: category, code: code, retryable: retryable, message: safe_message(category, code)}
480
+ payload = {category: category, code: code, retryable: retryable, message: safe_message(category, code)}
481
+ retry_after = retry_after_seconds(error)
482
+ payload[:retry_after_seconds] = retry_after if retry_after
483
+ payload
406
484
  end
407
485
  private_class_method :payload
408
486
 
487
+ def self.retry_after_seconds(error)
488
+ return unless error.respond_to?(:response) && error.response
489
+
490
+ headers = error.response.respond_to?(:headers) ? error.response.headers : error.response[:response_headers]
491
+ value = headers&.find { |key, _| key.to_s.casecmp?("retry-after") }&.last
492
+ delay = Float(value, exception: false)
493
+ delay if delay&.finite? && delay >= 0
494
+ end
495
+ private_class_method :retry_after_seconds
496
+
409
497
  def self.safe_message(category, code)
410
498
  "Chat request failed (#{category}/#{code})"
411
499
  end
@@ -5,6 +5,22 @@ require "ruby_llm"
5
5
 
6
6
  module AgentHarness
7
7
  module Api
8
+ # RubyLLM 2.0.0 flattens Responses API refusal deltas into ordinary text
9
+ # chunks and offers no public access to their event type. The gemspec pins
10
+ # that exact release so this compatibility shim cannot silently outlive the
11
+ # private parser shape it targets. Remove it when RubyLLM exposes refusals.
12
+ module RubyLlmResponsesStreamingRefusal
13
+ module RefusalChunk; end
14
+
15
+ def build_chunk(data)
16
+ super.tap do |chunk|
17
+ chunk.extend(RefusalChunk) if data["type"] == "response.refusal.delta"
18
+ end
19
+ end
20
+ end
21
+
22
+ RubyLLM::Protocols::Responses.prepend(RubyLlmResponsesStreamingRefusal)
23
+
8
24
  # Translates the normalized public chat values to RubyLLM public objects.
9
25
  class RubyLlmChatAdapter
10
26
  class UnsupportedOptionError < StandardError; end
@@ -14,16 +30,29 @@ module AgentHarness
14
30
  anthropic: %i[anthropic_api_key anthropic_api_base],
15
31
  openai: %i[openai_api_key openai_api_base]
16
32
  }.freeze
33
+ DEFAULT_REQUEST_TIMEOUT = 300
17
34
  OPENAI_UNSUPPLIED_CONFIG = %i[openai_organization_id openai_project_id openai_use_system_role].freeze
18
35
 
19
- def call(candidate:, messages:, tools:, max_output_tokens:, temperature:, stream:, timeout:, cancellation:, &on_event)
20
- context = build_context(candidate, timeout)
21
- chat = context.chat(model: candidate[:model], provider: candidate[:provider], protocol: ruby_llm_protocol(candidate),
22
- assume_model_exists: true)
23
- configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
24
- response = generate(chat, stream, cancellation, &on_event)
36
+ def call(candidate:, messages:, tools:, max_output_tokens:, temperature:, stream:, timeout:, cancellation:,
37
+ schema: nil, on_accounting: nil, prepared_chat: nil, &on_event)
38
+ provider_usage_reported = false
39
+ chat = prepared_chat || prepare(candidate:, messages:, tools:, max_output_tokens:, temperature:, timeout:,
40
+ schema:, on_accounting:, provider_usage: -> { provider_usage_reported })
41
+ response, streamed_refusal = generate(chat, stream, cancellation) do |event|
42
+ provider_usage_reported = true if event[:type] == :usage_updated
43
+ on_event&.call(event)
44
+ end
25
45
  emit_completed_tool_calls(response, &on_event) if stream
26
- normalize_response(response)
46
+ normalize_response(response, streamed_refusal: streamed_refusal)
47
+ end
48
+
49
+ def prepare(candidate:, messages:, tools:, max_output_tokens:, temperature:, timeout:, on_accounting: nil,
50
+ schema: nil, provider_usage: -> { false })
51
+ context = build_context(candidate, timeout, on_accounting, provider_usage)
52
+ context.chat(model: candidate[:model], provider: candidate[:provider], protocol: ruby_llm_protocol(candidate),
53
+ assume_model_exists: true).tap do |chat|
54
+ configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature, schema)
55
+ end
27
56
  end
28
57
 
29
58
  # RubyLLM keys streamed tool-call chunks by a stream index while
@@ -32,6 +61,7 @@ module AgentHarness
32
61
  # duplicate cumulative reports are not re-emitted.
33
62
  class StreamState
34
63
  attr_accessor :input_tokens, :output_tokens
64
+ attr_reader :refusal
35
65
 
36
66
  def initialize
37
67
  @provider_id_by_key = {}
@@ -39,6 +69,7 @@ module AgentHarness
39
69
  @latest_provider_id = nil
40
70
  @input_tokens = nil
41
71
  @output_tokens = nil
72
+ @refusal = false
42
73
  end
43
74
 
44
75
  # Links a stream chunk key to its provider call id, returning true
@@ -59,6 +90,10 @@ module AgentHarness
59
90
  total = (input_tokens + output_tokens) if input_tokens && output_tokens
60
91
  {input_tokens: input_tokens, output_tokens: output_tokens, total_tokens: total}
61
92
  end
93
+
94
+ def observe(chunk)
95
+ @refusal ||= chunk.is_a?(RubyLlmResponsesStreamingRefusal::RefusalChunk)
96
+ end
62
97
  end
63
98
 
64
99
  private
@@ -69,7 +104,7 @@ module AgentHarness
69
104
  candidate[:protocol]
70
105
  end
71
106
 
72
- def build_context(candidate, timeout)
107
+ def build_context(candidate, timeout, on_accounting, provider_usage)
73
108
  provider = candidate[:provider].to_sym
74
109
  config_keys = PROVIDER_CONFIG[provider]
75
110
  raise RubyLLM::ConfigurationError, "Unsupported chat provider: #{provider}" unless config_keys
@@ -80,10 +115,51 @@ module AgentHarness
80
115
  config.public_send("#{config_keys[1]}=", candidate[:endpoint])
81
116
  clear_unsupplied_openai_config(config) if provider == :openai
82
117
  config.max_retries = 0
118
+ config.instrumenter = UsageInstrumenter.new(on_accounting, provider_usage) if on_accounting
83
119
  apply_timeout(config, timeout)
84
120
  end
85
121
  end
86
122
 
123
+ # Retains only accounting instrumentation; other events may contain
124
+ # request content and are intentionally discarded.
125
+ class UsageInstrumenter
126
+ def initialize(callback, provider_usage)
127
+ @callback = callback
128
+ @provider_usage = provider_usage
129
+ end
130
+
131
+ def instrument(name, payload)
132
+ @callback.call(normalize(payload)) if name == "usage.ruby_llm"
133
+ yield(payload) if block_given?
134
+ end
135
+
136
+ private
137
+
138
+ def normalize(payload)
139
+ tokens = payload.fetch(:tokens)
140
+ cost = payload.fetch(:cost)
141
+ usage = tokens.to_h
142
+ usage[:total_tokens] = tokens.input + tokens.output if tokens.input && tokens.output
143
+ {
144
+ usage: usage,
145
+ cost: cost.total.nil? ? nil : cost.to_h.merge(source: cost_source(tokens), currency: "USD"),
146
+ provider_reported: provider_reported?(payload, usage)
147
+ }
148
+ end
149
+
150
+ def provider_reported?(payload, usage)
151
+ return false if usage.empty?
152
+ return true unless payload[:status]&.to_sym == :failed
153
+ return true if @provider_usage.call
154
+
155
+ usage.values.compact.any?(&:positive?)
156
+ end
157
+
158
+ def cost_source(tokens)
159
+ tokens.reported_cost.nil? ? :estimated : :provider_reported
160
+ end
161
+ end
162
+
87
163
  def clear_unsupplied_openai_config(config)
88
164
  OPENAI_UNSUPPLIED_CONFIG.each { |key| config.public_send("#{key}=", nil) }
89
165
  end
@@ -93,23 +169,23 @@ module AgentHarness
93
169
  raise UnsupportedOptionError, "RubyLLM does not support request-local connect timeouts"
94
170
  end
95
171
 
96
- seconds = timeout&.dig(:read_seconds)
97
- config.request_timeout = seconds if seconds
172
+ config.request_timeout = timeout&.dig(:read_seconds) || DEFAULT_REQUEST_TIMEOUT
98
173
  end
99
174
 
100
- def configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
175
+ def configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature, schema)
101
176
  chat.messages = normalize_messages(messages)
102
177
  chat.with_tools(tools.map { |tool| normalized_tool(tool) }) unless tools.empty?
103
178
  chat.with_headers(candidate[:headers] || {})
104
179
  chat.with_max_output_tokens(max_output_tokens) if max_output_tokens
105
180
  chat.with_temperature(temperature) unless temperature.nil?
181
+ chat.with_schema(schema) if schema
106
182
  end
107
183
 
108
184
  def generate(chat, stream, cancellation)
109
- return generate_without_events(chat, cancellation) unless stream
185
+ return [generate_without_events(chat, cancellation), false] unless stream
110
186
 
111
187
  state = StreamState.new
112
- chat.generate do |chunk|
188
+ response = chat.generate do |chunk|
113
189
  if cancelled?(cancellation)
114
190
  chat.cancel
115
191
  raise RubyLLM::CancelledError
@@ -117,6 +193,7 @@ module AgentHarness
117
193
 
118
194
  stream_events(chunk, state).each { |event| yield event }
119
195
  end
196
+ [response, state.refusal]
120
197
  end
121
198
 
122
199
  def generate_without_events(chat, cancellation)
@@ -182,6 +259,7 @@ module AgentHarness
182
259
  end
183
260
 
184
261
  def stream_events(chunk, state)
262
+ state.observe(chunk)
185
263
  [text_event(chunk), *tool_call_events(chunk, state), usage_event(chunk, state)].compact
186
264
  end
187
265
 
@@ -247,26 +325,54 @@ module AgentHarness
247
325
  end
248
326
  end
249
327
 
250
- def normalize_response(response)
328
+ def normalize_response(response, streamed_refusal: false)
251
329
  {
252
330
  content: response.content || "",
253
331
  model: response.model,
254
332
  finish_reason: response.finish_reason,
255
333
  usage: normalize_usage(response.tokens),
256
- tool_calls: Array(response.tool_calls&.values).map { |call| normalize_tool_call(call) }
334
+ tool_calls: Array(response.tool_calls&.values).map { |call| normalize_tool_call(call) },
335
+ refusal: streamed_refusal || refusal?(response)
257
336
  }
258
337
  end
259
338
 
339
+ def refusal?(response)
340
+ return true if response.finish_reason == :content_filter
341
+ return false unless response.respond_to?(:raw)
342
+
343
+ body = response.raw&.body
344
+ return false unless body.is_a?(Hash)
345
+
346
+ responses_api_refusal?(body) || chat_completions_refusal?(body)
347
+ end
348
+
349
+ def responses_api_refusal?(body)
350
+ Array(body["output"] || body[:output]).any? do |item|
351
+ next false unless item.is_a?(Hash)
352
+
353
+ Array(item["content"] || item[:content]).any? do |part|
354
+ part.is_a?(Hash) && (part["type"] || part[:type]) == "refusal"
355
+ end
356
+ end
357
+ end
358
+
359
+ def chat_completions_refusal?(body)
360
+ Array(body["choices"] || body[:choices]).any? do |choice|
361
+ next false unless choice.is_a?(Hash)
362
+
363
+ message = choice["message"] || choice[:message]
364
+ message.is_a?(Hash) && !(message["refusal"] || message[:refusal]).nil?
365
+ end
366
+ end
367
+
260
368
  def normalize_tool_call(call)
261
369
  {provider_id: call.id, name: call.name, arguments_json: JSON.generate(call.arguments || {})}
262
370
  end
263
371
 
264
372
  def normalize_usage(tokens)
265
- input = tokens&.input
266
- output = tokens&.output
267
- return unless input || output
373
+ return unless tokens&.to_h&.any?
268
374
 
269
- {input_tokens: input, output_tokens: output, total_tokens: (input && output) ? input + output : nil}
375
+ tokens.to_h.merge(total_tokens: (tokens.input && tokens.output) ? tokens.input + tokens.output : nil)
270
376
  end
271
377
  end
272
378
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json_schemer"
5
+
6
+ module AgentHarness
7
+ module Api
8
+ # Parses and validates one schema-constrained provider response.
9
+ class SchemaResponse
10
+ def initialize(schema)
11
+ @validator = JSONSchemer.schema(schema)
12
+ end
13
+
14
+ def call(response)
15
+ content = response[:content].to_s
16
+ return failure(content, :refusal) if response[:refusal]
17
+ return failure(content, :truncated_output) if response[:finish_reason]&.to_sym == :max_tokens
18
+
19
+ parsed = JSON.parse(content)
20
+ return failure(content, :invalid_schema) unless @validator.valid?(parsed)
21
+
22
+ {content: content, parsed: parsed, error: nil}
23
+ rescue JSON::ParserError
24
+ failure(content, :invalid_json)
25
+ end
26
+
27
+ private
28
+
29
+ def failure(content, code)
30
+ {
31
+ content: content,
32
+ parsed: nil,
33
+ error: {
34
+ category: :invalid_response,
35
+ code: code,
36
+ retryable: false,
37
+ message: "Schema response failed (invalid_response/#{code})"
38
+ }
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.41.0"
4
+ VERSION = "0.43.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -471,7 +471,9 @@ require_relative "agent_harness/error_taxonomy"
471
471
  require_relative "agent_harness/text_transport"
472
472
  require_relative "agent_harness/openai_compatible_transport"
473
473
  require_relative "agent_harness/conversation"
474
+ require_relative "agent_harness/api/attempt_report"
474
475
  require_relative "agent_harness/api/chat_transport"
476
+ require_relative "agent_harness/api/schema_response"
475
477
  require_relative "agent_harness/quota_status"
476
478
  require_relative "agent_harness/authentication"
477
479
  require_relative "agent_harness/provider_health_check"
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.41.0
4
+ version: 0.43.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -30,25 +30,33 @@ dependencies:
30
30
  - !ruby/object:Gem::Version
31
31
  version: '2.0'
32
32
  - !ruby/object:Gem::Dependency
33
- name: ruby_llm
33
+ name: json_schemer
34
34
  requirement: !ruby/object:Gem::Requirement
35
35
  requirements:
36
- - - ">="
37
- - !ruby/object:Gem::Version
38
- version: '2.0'
39
- - - "<"
36
+ - - "~>"
40
37
  - !ruby/object:Gem::Version
41
- version: '3.0'
38
+ version: '2.4'
42
39
  type: :runtime
43
40
  prerelease: false
44
41
  version_requirements: !ruby/object:Gem::Requirement
45
42
  requirements:
46
- - - ">="
43
+ - - "~>"
47
44
  - !ruby/object:Gem::Version
48
- version: '2.0'
49
- - - "<"
45
+ version: '2.4'
46
+ - !ruby/object:Gem::Dependency
47
+ name: ruby_llm
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - '='
50
51
  - !ruby/object:Gem::Version
51
- version: '3.0'
52
+ version: 2.0.0
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - '='
58
+ - !ruby/object:Gem::Version
59
+ version: 2.0.0
52
60
  - !ruby/object:Gem::Dependency
53
61
  name: rake
54
62
  requirement: !ruby/object:Gem::Requirement
@@ -135,8 +143,10 @@ files:
135
143
  - json-2.18.1.gem
136
144
  - lib/agent-harness.rb
137
145
  - lib/agent_harness.rb
146
+ - lib/agent_harness/api/attempt_report.rb
138
147
  - lib/agent_harness/api/chat_transport.rb
139
148
  - lib/agent_harness/api/ruby_llm_chat_adapter.rb
149
+ - lib/agent_harness/api/schema_response.rb
140
150
  - lib/agent_harness/authentication.rb
141
151
  - lib/agent_harness/command_executor.rb
142
152
  - lib/agent_harness/configuration.rb