agent-harness 0.40.0 → 0.42.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: ee00d0e61838facc87483e6bdcac764ed26dc5bb227a15075b4e8f0daa8983d4
4
- data.tar.gz: 6034f1733639711ad2b7df6bf9c26da839d45c26d1ddb6b0b4e5ba413854fc8a
3
+ metadata.gz: 9c2a3cf60957839163bbda4f4e202f663ee68915e49ba3853abce85b5f6f16e9
4
+ data.tar.gz: b5a9950414fc72fe2626abbb3ef50275e2e28267346414d04d330d3ea37c1297
5
5
  SHA512:
6
- metadata.gz: 130a2897522e8b1aa73b2a9cf6094bd24b3b529696445e84356a78faa02540e0c4c2677602e306fca60e1f1a3040e48c04b3ff0a9ca28f144a24de6fc3fa5887
7
- data.tar.gz: 647ab39ee27728d28a3441ccbbd84edd3521e7e434a120ac236e411849b22aa431ae7fc6dbe499ec8c525aee6159c9fdba42a5ee8ff7ec329281dd7ea3a4ad33
6
+ metadata.gz: b6147210553af7975d99dbf53cc52c0b37e7ba25c8424a9cfb24a290bae9172d25bcbc8a6e5b489d1b2ae363251facc756ea0eaae55a6efd3904cdb64fcda025
7
+ data.tar.gz: f869749041ff450642b8ba940a3f0f9f8ea2cd7a3d788b2414ae7f0115dcb5fde4dcb7f5aa725b4e1ba2216568a1412e2bf5439c47b61bf75b2d06390faebfae
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.40.0"
2
+ ".": "0.42.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.42.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.41.0...agent-harness/v0.42.0) (2026-09-25)
9
+
10
+
11
+ ### Features
12
+
13
+ * 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))
14
+
15
+ ## [0.41.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.40.0...agent-harness/v0.41.0) (2026-09-25)
16
+
17
+
18
+ ### Features
19
+
20
+ * Normalize API Chat Transport, Tools and Streaming (RDR-072) ([#441](https://github.com/viamin/agent-harness/issues/441)) ([761c118](https://github.com/viamin/agent-harness/commit/761c11842ccacd18d91e9412f71cb98d76afa3b2))
21
+
8
22
  ## [0.40.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.39.0...agent-harness/v0.40.0) (2026-09-25)
9
23
 
10
24
 
@@ -7,19 +7,83 @@ usage, and optional conversation-persistence work.
7
7
 
8
8
  ## Status and rollout boundary
9
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.
10
+ RDR-072's rollout guard was **docs-only** for the design phase. The normalized
11
+ chat and attempt-accounting capabilities described below are now implemented
12
+ behind `AgentHarness::Api::ChatTransport`. Other capabilities remain design contracts and each still needs
13
+ its own failing-first contract tests, implementation, release evidence, and
14
+ downstream adoption evidence before a caller enables it.
15
15
 
16
16
  Existing CLI and subscription behavior remains the default. Existing
17
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.
18
+ interfaces remain available and unchanged; they are not aliases for the
19
+ normalized API. A caller must not infer support for another capability from a
20
+ gem version or issue closure.
20
21
 
21
22
  Normative words such as MUST and MUST NOT describe the future public boundary.
22
23
 
24
+ ## Shipped normalized chat surface
25
+
26
+ `AgentHarness::Api::ChatTransport#call` implements one normalized assistant
27
+ response while leaving the conversation loop and all application tool
28
+ execution with the caller:
29
+
30
+ ```ruby
31
+ transport = AgentHarness::Api::ChatTransport.new
32
+ result = transport.call(request.merge(
33
+ operation: :chat,
34
+ messages: [
35
+ {id: "system-1", role: :system,
36
+ content: [{type: :text, text: "Be concise"}]},
37
+ {id: "user-1", role: :user,
38
+ content: [{type: :text, text: "Summarize this"}]}
39
+ ],
40
+ tools: [{
41
+ name: "lookup",
42
+ description: "Looks up a record",
43
+ input_schema: {type: "object", properties: {id: {type: "string"}}}
44
+ }],
45
+ max_output_tokens: 1_000,
46
+ stream: true
47
+ ), observer: ->(event) { events << event })
48
+ ```
49
+
50
+ The returned value is the normalized result hash in this document. The
51
+ observer is either callable or responds to `on_chat_event`. It receives ordered
52
+ events with request/attempt identity and sequence numbers. This transport never
53
+ invokes a supplied tool; callers append completed tool results to a later
54
+ request. A failed partial stream is terminal, so its content cannot be appended
55
+ to a fallback response and its tool calls cannot be replayed automatically.
56
+
57
+ Streamed tool-call arguments are emitted as raw, appendable JSON fragments
58
+ correlated to one `tool_call_started` event per provider call, and cumulative
59
+ provider token counts are emitted as deduplicated `usage_updated` events. An
60
+ observer that raises aborts the in-flight request and surfaces as
61
+ `AgentHarness::Api::ChatTransport::ObserverError` with the original failure as
62
+ its `cause`; it is never classified as a provider error, never retried, and the
63
+ failed observer is not invoked again.
64
+
65
+ The verified scopes are Anthropic with `protocol: :messages`, OpenAI with
66
+ `protocol: :responses` or `:chat_completions`, and OpenAI-compatible endpoints
67
+ with `provider: :openai`, an explicit `endpoint`, and
68
+ `protocol: :chat_completions`. Compatible endpoints that do not implement the
69
+ Responses API must select `:chat_completions`; the transport never probes and
70
+ silently switches protocols. Only `authentication_mode: :api_key` is currently
71
+ supported.
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.
78
+
79
+ Unknown model IDs are allowed only because a complete provider and protocol
80
+ are explicit in every candidate (`assume_model_exists: true` in the RubyLLM
81
+ adapter). This skips registry validation; it does not assert that the endpoint
82
+ supports the model. Provider rejection returns a classified failure. Custom
83
+ endpoints retain the selected provider's wire protocol and authentication
84
+ shape. Custom provider types, authentication modes, media content, and
85
+ automatic protocol discovery are not supported by this capability.
86
+
23
87
  ## Ownership boundary
24
88
 
25
89
  AgentHarness owns protocol translation and one bounded provider request:
@@ -272,6 +336,23 @@ observer so durable accounting can persist an attempt even when no message is
272
336
  created. Callers deduplicate on `attempt_id`. Cost identifies its source as
273
337
  provider-reported or harness-estimated; unknown cost remains `nil`.
274
338
 
339
+ The observer receives `attempt_completed` after every physical request, with
340
+ the same report later returned in `result[:attempts]`. Delivery is at least
341
+ once across process recovery: consumers MUST enforce a unique key on
342
+ `attempt_id` and treat a repeated report as an idempotent upsert, not another
343
+ charge. `AttemptReport.from_h` restores JSON-decoded reports. Stored cost
344
+ includes USD component amounts, `source` (`provider_reported` or `estimated`),
345
+ and `priced_at`; restoration never consults current prices. Missing counts and
346
+ prices remain absent/`nil`, while a reported zero remains zero. Usage may also
347
+ include `cache_read_tokens`, `cache_write_tokens`, and `thinking_tokens`.
348
+
349
+ Attempt events contain only identifiers, provider/model, outcome, normalized
350
+ usage/cost, timestamps, and sanitized classified errors. They do not include
351
+ prompts, messages, request headers, endpoints, or credentials. RubyLLM API chat
352
+ requests are in this ledger. Existing CLI providers, `TextTransport`,
353
+ `OpenAICompatibleTransport`, token trackers, embeddings, and other
354
+ non-`Api::ChatTransport` paths remain outside it.
355
+
275
356
  Only errors classified `transient` are eligible for bounded request retry:
276
357
  connection failure, timeout before a partial stream, rate limit, server error,
277
358
  service unavailable, and overload. Authentication, authorization, billing,
@@ -505,6 +586,27 @@ types stay behind the harness boundary.
505
586
 
506
587
  ## Compatibility and release evidence
507
588
 
589
+ ### Attempt-accounting capability evidence
590
+
591
+ - Release: pending the first published version containing issue #435; a Git
592
+ branch or tag alone is not downstream adoption evidence.
593
+ - Scope: normalized chat through `Api::ChatTransport` for Anthropic Messages
594
+ and OpenAI Responses/Chat Completions (including compatible endpoints), with
595
+ request-local API-key authentication. It is stacked on the normalized chat
596
+ capability from #433.
597
+ - Verification: the API contract specs cover request-local credentials,
598
+ endpoint/header isolation, error classification, bounded non-nested retry,
599
+ fallback, cancellation, partial usage, cache usage, repeated identity,
600
+ observer redaction, and JSON reload with preserved pricing. The full upstream
601
+ suite and lint run on the repository's supported Ruby environment.
602
+ - Migration: no Rails tables or migrations are loaded or required. Paid and
603
+ agent-image consumer versions remain unverified and MUST NOT adopt this
604
+ capability until their integration suites record the exact released gem and
605
+ image versions.
606
+ - Retained paths: CLI/subscription providers, legacy HTTP transports,
607
+ embeddings, and token trackers remain outside this ledger and require
608
+ separate migration issues.
609
+
508
610
  AgentHarness currently supports Ruby 3.2 and later and must remain usable as a
509
611
  plain Ruby gem. RubyLLM 2.0.0 itself supports Ruby 3.1.3 and later, but adding it
510
612
  would introduce Faraday, event-stream parsing, Schematist, Marcel, and Zeitwerk
@@ -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
@@ -0,0 +1,455 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "ruby_llm"
5
+ require_relative "attempt_report"
6
+ require_relative "ruby_llm_chat_adapter"
7
+
8
+ module AgentHarness
9
+ module Api
10
+ # Executes one normalized chat response without running application tools.
11
+ class ChatTransport
12
+ RESERVED_HEADERS = {
13
+ anthropic: %w[x-api-key anthropic-version],
14
+ openai: %w[authorization]
15
+ }.freeze
16
+ PROTOCOLS = {
17
+ anthropic: %i[messages],
18
+ openai: %i[responses chat_completions]
19
+ }.freeze
20
+ DEFAULT_RETRY = {max_attempts: 1, base_delay_seconds: 0, max_delay_seconds: 0}.freeze
21
+
22
+ # Raised when the caller's observer fails. Observer bugs stay outside
23
+ # provider error classification, abort the in-flight request, and are
24
+ # never re-invoked with a synthetic terminal event.
25
+ class ObserverError < StandardError
26
+ end
27
+
28
+ def initialize(adapter: RubyLlmChatAdapter.new, id_generator: -> { SecureRandom.uuid }, sleeper: Kernel.method(:sleep))
29
+ @adapter = adapter
30
+ @id_generator = id_generator
31
+ @sleeper = sleeper
32
+ end
33
+
34
+ def call(request, observer: nil, &on_event)
35
+ execution = Execution.new(request, adapter: @adapter, id_generator: @id_generator,
36
+ sleeper: @sleeper, observer: observer || on_event)
37
+ execution.call
38
+ end
39
+
40
+ # Holds mutable state for exactly one request, keeping the transport reusable.
41
+ class Execution
42
+ def initialize(request, adapter:, id_generator:, sleeper:, observer:)
43
+ @request = symbolize(request)
44
+ @adapter = adapter
45
+ @id_generator = id_generator
46
+ @sleeper = sleeper
47
+ @observer = observer
48
+ @attempts = []
49
+ @sequence = 0
50
+ @partial_content = +""
51
+ @stream_tool_calls = {}
52
+ @tool_ids = {}
53
+ validate!
54
+ end
55
+
56
+ def call
57
+ return cancelled_result unless active?
58
+
59
+ candidates.each_with_index do |candidate, candidate_index|
60
+ result = attempt_candidate(candidate, candidate_index)
61
+ return result if terminal?(result)
62
+ end
63
+ failed_result(@last_error, @last_candidate)
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :request, :attempts
69
+
70
+ def attempt_candidate(candidate, candidate_index)
71
+ loop do
72
+ return cancelled_result(candidate) unless active?
73
+ return failed_result(@last_error, candidate) if attempts.length >= retry_config[:max_attempts]
74
+
75
+ result = perform_attempt(candidate)
76
+ return result if result[:status] == :succeeded || result[:status] == :partial
77
+ return cancelled_result(candidate) unless active?
78
+
79
+ @last_error = result[:error]
80
+ @last_candidate = candidate
81
+ return result if select_fallback(result, candidate_index)
82
+ return result unless retryable?(result)
83
+
84
+ backoff
85
+ end
86
+ end
87
+
88
+ def perform_attempt(candidate)
89
+ accounting = nil
90
+ provider_usage = -> { !streamed_usage.nil? }
91
+ prepared_chat = @adapter.prepare(
92
+ candidate: candidate,
93
+ messages: request[:messages],
94
+ tools: request[:tools] || [],
95
+ max_output_tokens: request[:max_output_tokens],
96
+ temperature: request[:temperature],
97
+ timeout: request[:timeout],
98
+ on_accounting: ->(facts) { accounting = facts },
99
+ provider_usage: provider_usage
100
+ )
101
+ attempt_id = @id_generator.call
102
+ started_at = Time.now.utc
103
+ emitted = false
104
+ streamed_usage = nil
105
+ emit(:response_started, attempt_id:, candidate: candidate_identity(candidate))
106
+
107
+ adapter_result = @adapter.call(
108
+ candidate: candidate,
109
+ messages: request[:messages],
110
+ tools: request[:tools] || [],
111
+ max_output_tokens: request[:max_output_tokens],
112
+ temperature: request[:temperature],
113
+ stream: request[:stream] == true,
114
+ timeout: request[:timeout],
115
+ cancellation: request[:cancellation],
116
+ on_accounting: ->(facts) { accounting = facts },
117
+ prepared_chat: prepared_chat
118
+ ) do |event|
119
+ event = normalize_stream_event(event)
120
+ emitted = true if output_event?(event)
121
+ streamed_usage = event.except(:type) if event[:type] == :usage_updated
122
+ accumulate(event)
123
+ emit(event.fetch(:type), attempt_id:, **event.except(:type))
124
+ end
125
+ raise RubyLLM::CancelledError unless active?
126
+
127
+ success(candidate, attempt_id, started_at, adapter_result, accounting)
128
+ rescue ObserverError
129
+ raise
130
+ rescue => error
131
+ return failed_result(classify(error), candidate) unless attempt_id
132
+
133
+ failure(candidate, attempt_id, started_at, classify(error), partial: emitted,
134
+ accounting: accounting || {
135
+ usage: streamed_usage, provider_reported: !streamed_usage.nil?
136
+ })
137
+ end
138
+
139
+ def success(candidate, attempt_id, started_at, adapter_result, accounting)
140
+ accounting ||= {usage: adapter_result[:usage], provider_reported: !adapter_result[:usage].nil?}
141
+ append_attempt(candidate, attempt_id, started_at, :succeeded, **accounting)
142
+ result = base_result(candidate).merge(
143
+ status: :succeeded,
144
+ content: adapter_result[:content] || "",
145
+ tool_calls: normalize_tool_calls(adapter_result[:tool_calls]),
146
+ finish_reason: adapter_result[:finish_reason],
147
+ usage: aggregate_usage,
148
+ error: nil
149
+ )
150
+ emit(:response_completed, attempt_id:, result: result)
151
+ result
152
+ end
153
+
154
+ def failure(candidate, attempt_id, started_at, error, partial:, accounting:)
155
+ status = failure_status(error, partial)
156
+ error = error.merge(retryable: false) if partial
157
+ append_attempt(candidate, attempt_id, started_at, status, error: error, **accounting)
158
+ result = base_result(candidate).merge(
159
+ status: status,
160
+ content: partial ? @partial_content.dup : "",
161
+ tool_calls: partial_tool_calls,
162
+ finish_reason: nil,
163
+ usage: aggregate_usage,
164
+ error: error
165
+ )
166
+ emit((status == :cancelled) ? :response_cancelled : :response_failed, attempt_id:, result: result)
167
+ result
168
+ end
169
+
170
+ def fallback_allowed?(result, candidate_index)
171
+ return false if result[:status] == :partial
172
+ return false unless fallback_categories.include?(result.dig(:error, :category))
173
+ return false unless candidates[candidate_index + 1]
174
+
175
+ true
176
+ end
177
+
178
+ def select_fallback(result, candidate_index)
179
+ return false unless fallback_allowed?(result, candidate_index)
180
+
181
+ next_candidate = candidates[candidate_index + 1]
182
+ emit(:fallback_selected, attempt_id: attempts.last[:attempt_id],
183
+ from: candidate_identity(candidates[candidate_index]),
184
+ to: candidate_identity(next_candidate), error: result[:error])
185
+ true
186
+ end
187
+
188
+ def retryable?(result)
189
+ result.dig(:error, :retryable) && attempts.length < retry_config[:max_attempts]
190
+ end
191
+
192
+ def terminal?(result)
193
+ return true if %i[succeeded partial cancelled].include?(result[:status])
194
+ return true unless fallback_categories.include?(result.dig(:error, :category))
195
+
196
+ attempts.length >= retry_config[:max_attempts]
197
+ end
198
+
199
+ def backoff
200
+ exponent = [attempts.length - 1, 0].max
201
+ delay = retry_config[:base_delay_seconds] * (2**exponent)
202
+ delay = [delay, @last_error[:retry_after_seconds].to_f].max
203
+ cap = retry_config[:max_delay_seconds]
204
+ remaining = cap.nil? ? delay : [delay, cap].min
205
+ while remaining.positive? && active?
206
+ interval = [remaining, 0.05].min
207
+ @sleeper.call(interval)
208
+ remaining -= interval
209
+ end
210
+ end
211
+
212
+ def emit(type, attempt_id:, **payload)
213
+ @sequence += 1
214
+ event = payload.merge(type: type, request_id: request[:request_id], attempt_id: attempt_id, sequence: @sequence)
215
+ deliver(event) if @observer
216
+ end
217
+
218
+ # Observer exceptions are caller bugs, not provider failures: they
219
+ # abort the in-flight request and surface directly instead of being
220
+ # classified or retried as provider errors.
221
+ def deliver(event)
222
+ @observer.respond_to?(:on_chat_event) ? @observer.on_chat_event(event) : @observer.call(event)
223
+ rescue => error
224
+ raise ObserverError, "chat observer failed: #{error.class} #{error.message}", error.backtrace
225
+ end
226
+
227
+ def accumulate(event)
228
+ @partial_content << event[:content].to_s if event[:type] == :text_delta
229
+ return unless event[:id]
230
+
231
+ call = (@stream_tool_calls[event[:id]] ||= {
232
+ id: event[:id], provider_id: event[:provider_id], name: event[:name],
233
+ arguments_json: +"", status: :incomplete
234
+ })
235
+ call[:arguments_json] << event[:arguments_json].to_s if event[:type] == :tool_call_delta
236
+ if event[:type] == :tool_call_completed
237
+ call[:arguments_json] = event[:arguments_json]
238
+ call[:status] = :completed
239
+ end
240
+ end
241
+
242
+ def output_event?(event)
243
+ %i[text_delta tool_call_started tool_call_delta tool_call_completed].include?(event[:type])
244
+ end
245
+
246
+ def partial_tool_calls
247
+ @stream_tool_calls.values.map(&:dup)
248
+ end
249
+
250
+ def normalize_tool_calls(tool_calls)
251
+ Array(tool_calls).map do |call|
252
+ id = call[:provider_id].nil? ? @id_generator.call : tool_id(call[:provider_id])
253
+ call.merge(id: id, status: :completed)
254
+ end
255
+ end
256
+
257
+ def normalize_stream_event(event)
258
+ return event unless event[:provider_id]
259
+
260
+ event.merge(id: tool_id(event[:provider_id]))
261
+ end
262
+
263
+ def tool_id(provider_id)
264
+ @tool_ids[provider_id] ||= @id_generator.call
265
+ end
266
+
267
+ def base_result(candidate)
268
+ {
269
+ request_id: request[:request_id],
270
+ provider: candidate&.dig(:provider),
271
+ model: candidate&.dig(:model),
272
+ protocol: candidate&.dig(:protocol),
273
+ authentication_mode: candidate&.dig(:authentication_mode),
274
+ parsed: nil,
275
+ attempts: attempts.dup,
276
+ provider_request_id: nil
277
+ }
278
+ end
279
+
280
+ def failed_result(error, candidate)
281
+ base_result(candidate).merge(status: :failed, content: "", tool_calls: [], finish_reason: nil,
282
+ usage: aggregate_usage, error: error)
283
+ end
284
+
285
+ def cancelled_result(candidate = candidates.first)
286
+ error = {category: :cancelled, code: :cancelled, retryable: false, message: "Request cancelled"}
287
+ base_result(candidate).merge(status: :cancelled, content: "", tool_calls: [], finish_reason: nil,
288
+ usage: aggregate_usage, error: error)
289
+ end
290
+
291
+ def append_attempt(candidate, attempt_id, started_at, status, error: nil, usage: nil, cost: nil,
292
+ provider_reported: false)
293
+ cost = cost.merge(priced_at: Time.now.utc) if cost
294
+ report = AttemptReport.new(
295
+ attempt_id: attempt_id,
296
+ request_id: request[:request_id],
297
+ number: attempts.length + 1,
298
+ provider: candidate[:provider],
299
+ model: candidate[:model],
300
+ status: status,
301
+ started_at: started_at.iso8601(6),
302
+ finished_at: Time.now.utc.iso8601(6),
303
+ usage: usage,
304
+ cost: cost,
305
+ provider_reported: provider_reported,
306
+ error: error
307
+ ).attributes
308
+ attempts << report
309
+ emit(:attempt_completed, attempt_id:, attempt: report)
310
+ end
311
+
312
+ def aggregate_usage
313
+ reports = attempts.filter_map { |attempt| attempt[:usage] }
314
+ return if reports.empty?
315
+
316
+ %i[input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens total_tokens].to_h do |key|
317
+ values = reports.filter_map { |usage| usage[key] }
318
+ [key, values.empty? ? nil : values.sum]
319
+ end.compact
320
+ end
321
+
322
+ def classify(error)
323
+ ErrorClassifier.call(error)
324
+ end
325
+
326
+ def failure_status(error, partial)
327
+ return :cancelled if error[:category] == :cancelled
328
+ return :partial if partial
329
+
330
+ :failed
331
+ end
332
+
333
+ def candidates = request[:candidates]
334
+ def retry_config = @retry_config ||= DEFAULT_RETRY.merge(request[:retry] || {})
335
+ def fallback_categories = Array(request.dig(:fallback, :on_error_categories)).map(&:to_sym)
336
+
337
+ def active?
338
+ token = request[:cancellation]
339
+ return true unless token
340
+
341
+ cancelled = token.respond_to?(:cancelled?) ? token.cancelled? : token.call
342
+ !cancelled
343
+ end
344
+
345
+ def validate!
346
+ raise ArgumentError, "operation must be :chat" unless request[:operation]&.to_sym == :chat
347
+ raise ArgumentError, "request_id is required" if request[:request_id].to_s.empty?
348
+ raise ArgumentError, "candidates must not be empty" if !request[:candidates].is_a?(Array) || request[:candidates].empty?
349
+ raise ArgumentError, "messages must be an array" unless request[:messages].is_a?(Array)
350
+ validate_attempt_limit!
351
+ candidates.each { |candidate| validate_candidate!(candidate) }
352
+ end
353
+
354
+ def validate_attempt_limit!
355
+ limit = retry_config[:max_attempts]
356
+ raise ArgumentError, "retry.max_attempts must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
357
+ end
358
+
359
+ def validate_candidate!(candidate)
360
+ candidate.replace(symbolize(candidate))
361
+ %i[provider model protocol authentication_mode credentials].each do |key|
362
+ raise ArgumentError, "candidate.#{key} is required" if candidate[key].nil?
363
+ end
364
+ raise ArgumentError, "only api_key authentication is supported" unless candidate[:authentication_mode].to_sym == :api_key
365
+ supported_protocols = PROTOCOLS[candidate[:provider].to_sym]
366
+ unless supported_protocols&.include?(candidate[:protocol].to_sym)
367
+ raise ArgumentError, "unsupported provider/protocol combination"
368
+ end
369
+
370
+ headers = candidate[:headers] || {}
371
+ reserved_names = RESERVED_HEADERS.fetch(candidate[:provider].to_sym, RESERVED_HEADERS.values.flatten)
372
+ reserved = headers.keys.map { |key| key.to_s.downcase } & reserved_names
373
+ raise ArgumentError, "reserved header override: #{reserved.first}" if reserved.any?
374
+ end
375
+
376
+ def candidate_identity(candidate)
377
+ candidate.slice(:provider, :model, :protocol, :authentication_mode)
378
+ end
379
+
380
+ def symbolize(value)
381
+ if value.is_a?(Hash)
382
+ return value.each_with_object({}) do |(key, child), normalized|
383
+ symbol = key.to_sym
384
+ normalized[symbol] = (symbol == :headers) ? child.dup : symbolize(child)
385
+ end
386
+ end
387
+ return value.map { |child| symbolize(child) } if value.is_a?(Array)
388
+
389
+ value
390
+ end
391
+ end
392
+ end
393
+
394
+ module ErrorClassifier
395
+ TRANSIENT = {
396
+ Faraday::TimeoutError => :timeout,
397
+ Faraday::ConnectionFailed => :connection_failed,
398
+ Faraday::SSLError => :connection_failed,
399
+ RubyLLM::RateLimitError => :rate_limited,
400
+ RubyLLM::ServerError => :server_error,
401
+ RubyLLM::ServiceUnavailableError => :service_unavailable,
402
+ RubyLLM::OverloadedError => :overloaded
403
+ }.freeze
404
+
405
+ NON_RETRYABLE = {
406
+ RubyLlmChatAdapter::MissingCredentialError => [:authentication, :invalid_credential],
407
+ RubyLLM::UnauthorizedError => [:authentication, :invalid_credential],
408
+ RubyLLM::ForbiddenError => [:authorization, :permission_denied],
409
+ RubyLLM::PaymentRequiredError => [:billing, :billing_unavailable],
410
+ RubyLLM::ContextLengthExceededError => [:context_length, :context_length_exceeded],
411
+ RubyLLM::BadRequestError => [:invalid_request, :invalid_request],
412
+ RubyLlmChatAdapter::UnsupportedOptionError => [:unsupported, :unsupported_capability],
413
+ RubyLLM::UnsupportedServerToolError => [:unsupported, :unsupported_capability],
414
+ RubyLLM::ToolCallParseError => [:invalid_response, :invalid_tool_arguments],
415
+ JSON::ParserError => [:invalid_response, :invalid_tool_arguments],
416
+ RubyLLM::ModelNotFoundError => [:configuration, :invalid_configuration],
417
+ RubyLLM::ConfigurationError => [:configuration, :invalid_configuration],
418
+ RubyLLM::CancelledError => [:cancelled, :cancelled]
419
+ }.freeze
420
+
421
+ def self.call(error)
422
+ transient = TRANSIENT.find { |klass, _| error.is_a?(klass) }
423
+ return payload(error, :transient, transient.last, true) if transient
424
+
425
+ permanent = NON_RETRYABLE.find { |klass, _| error.is_a?(klass) }
426
+ return payload(error, *permanent.last, false) if permanent
427
+
428
+ payload(error, :unknown, :unclassified_provider_error, false)
429
+ end
430
+
431
+ def self.payload(error, category, code, retryable)
432
+ payload = {category: category, code: code, retryable: retryable, message: safe_message(category, code)}
433
+ retry_after = retry_after_seconds(error)
434
+ payload[:retry_after_seconds] = retry_after if retry_after
435
+ payload
436
+ end
437
+ private_class_method :payload
438
+
439
+ def self.retry_after_seconds(error)
440
+ return unless error.respond_to?(:response) && error.response
441
+
442
+ headers = error.response.respond_to?(:headers) ? error.response.headers : error.response[:response_headers]
443
+ value = headers&.find { |key, _| key.to_s.casecmp?("retry-after") }&.last
444
+ delay = Float(value, exception: false)
445
+ delay if delay&.finite? && delay >= 0
446
+ end
447
+ private_class_method :retry_after_seconds
448
+
449
+ def self.safe_message(category, code)
450
+ "Chat request failed (#{category}/#{code})"
451
+ end
452
+ private_class_method :safe_message
453
+ end
454
+ end
455
+ end
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ruby_llm"
5
+
6
+ module AgentHarness
7
+ module Api
8
+ # Translates the normalized public chat values to RubyLLM public objects.
9
+ class RubyLlmChatAdapter
10
+ class UnsupportedOptionError < StandardError; end
11
+ class MissingCredentialError < StandardError; end
12
+
13
+ PROVIDER_CONFIG = {
14
+ anthropic: %i[anthropic_api_key anthropic_api_base],
15
+ openai: %i[openai_api_key openai_api_base]
16
+ }.freeze
17
+ DEFAULT_REQUEST_TIMEOUT = 300
18
+ OPENAI_UNSUPPLIED_CONFIG = %i[openai_organization_id openai_project_id openai_use_system_role].freeze
19
+
20
+ def call(candidate:, messages:, tools:, max_output_tokens:, temperature:, stream:, timeout:, cancellation:,
21
+ on_accounting: nil, prepared_chat: nil, &on_event)
22
+ provider_usage_reported = false
23
+ chat = prepared_chat || prepare(candidate:, messages:, tools:, max_output_tokens:, temperature:, timeout:,
24
+ on_accounting:, provider_usage: -> { provider_usage_reported })
25
+ response = generate(chat, stream, cancellation) do |event|
26
+ provider_usage_reported = true if event[:type] == :usage_updated
27
+ on_event&.call(event)
28
+ end
29
+ emit_completed_tool_calls(response, &on_event) if stream
30
+ normalize_response(response)
31
+ end
32
+
33
+ def prepare(candidate:, messages:, tools:, max_output_tokens:, temperature:, timeout:, on_accounting: nil,
34
+ provider_usage: -> { false })
35
+ context = build_context(candidate, timeout, on_accounting, provider_usage)
36
+ context.chat(model: candidate[:model], provider: candidate[:provider], protocol: ruby_llm_protocol(candidate),
37
+ assume_model_exists: true).tap do |chat|
38
+ configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
39
+ end
40
+ end
41
+
42
+ # RubyLLM keys streamed tool-call chunks by a stream index while
43
+ # continuation chunks carry no call id, so correlation state must live
44
+ # between chunks. Also remembers the latest cumulative token counts so
45
+ # duplicate cumulative reports are not re-emitted.
46
+ class StreamState
47
+ attr_accessor :input_tokens, :output_tokens
48
+
49
+ def initialize
50
+ @provider_id_by_key = {}
51
+ @started_ids = {}
52
+ @latest_provider_id = nil
53
+ @input_tokens = nil
54
+ @output_tokens = nil
55
+ end
56
+
57
+ # Links a stream chunk key to its provider call id, returning true
58
+ # only the first time +provider_id+ is seen.
59
+ def start(stream_key, provider_id)
60
+ @provider_id_by_key[stream_key] = provider_id unless stream_key.nil?
61
+ @latest_provider_id = provider_id
62
+ return false if @started_ids.key?(provider_id)
63
+
64
+ @started_ids[provider_id] = true
65
+ end
66
+
67
+ def provider_id(stream_key)
68
+ stream_key.nil? ? @latest_provider_id : @provider_id_by_key[stream_key]
69
+ end
70
+
71
+ def usage
72
+ total = (input_tokens + output_tokens) if input_tokens && output_tokens
73
+ {input_tokens: input_tokens, output_tokens: output_tokens, total_tokens: total}
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def ruby_llm_protocol(candidate)
80
+ return :anthropic if candidate[:provider].to_sym == :anthropic && candidate[:protocol].to_sym == :messages
81
+
82
+ candidate[:protocol]
83
+ end
84
+
85
+ def build_context(candidate, timeout, on_accounting, provider_usage)
86
+ provider = candidate[:provider].to_sym
87
+ config_keys = PROVIDER_CONFIG[provider]
88
+ raise RubyLLM::ConfigurationError, "Unsupported chat provider: #{provider}" unless config_keys
89
+ raise MissingCredentialError, "API key is required" if candidate.dig(:credentials, :api_key).to_s.empty?
90
+
91
+ RubyLLM.context do |config|
92
+ config.public_send("#{config_keys[0]}=", candidate.dig(:credentials, :api_key))
93
+ config.public_send("#{config_keys[1]}=", candidate[:endpoint])
94
+ clear_unsupplied_openai_config(config) if provider == :openai
95
+ config.max_retries = 0
96
+ config.instrumenter = UsageInstrumenter.new(on_accounting, provider_usage) if on_accounting
97
+ apply_timeout(config, timeout)
98
+ end
99
+ end
100
+
101
+ # Retains only accounting instrumentation; other events may contain
102
+ # request content and are intentionally discarded.
103
+ class UsageInstrumenter
104
+ def initialize(callback, provider_usage)
105
+ @callback = callback
106
+ @provider_usage = provider_usage
107
+ end
108
+
109
+ def instrument(name, payload)
110
+ @callback.call(normalize(payload)) if name == "usage.ruby_llm"
111
+ yield(payload) if block_given?
112
+ end
113
+
114
+ private
115
+
116
+ def normalize(payload)
117
+ tokens = payload.fetch(:tokens)
118
+ cost = payload.fetch(:cost)
119
+ usage = tokens.to_h
120
+ usage[:total_tokens] = tokens.input + tokens.output if tokens.input && tokens.output
121
+ {
122
+ usage: usage,
123
+ cost: cost.total.nil? ? nil : cost.to_h.merge(source: cost_source(tokens), currency: "USD"),
124
+ provider_reported: provider_reported?(payload, usage)
125
+ }
126
+ end
127
+
128
+ def provider_reported?(payload, usage)
129
+ return false if usage.empty?
130
+ return true unless payload[:status]&.to_sym == :failed
131
+ return true if @provider_usage.call
132
+
133
+ usage.values.compact.any?(&:positive?)
134
+ end
135
+
136
+ def cost_source(tokens)
137
+ tokens.reported_cost.nil? ? :estimated : :provider_reported
138
+ end
139
+ end
140
+
141
+ def clear_unsupplied_openai_config(config)
142
+ OPENAI_UNSUPPLIED_CONFIG.each { |key| config.public_send("#{key}=", nil) }
143
+ end
144
+
145
+ def apply_timeout(config, timeout)
146
+ if timeout&.dig(:connect_seconds)
147
+ raise UnsupportedOptionError, "RubyLLM does not support request-local connect timeouts"
148
+ end
149
+
150
+ config.request_timeout = timeout&.dig(:read_seconds) || DEFAULT_REQUEST_TIMEOUT
151
+ end
152
+
153
+ def configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
154
+ chat.messages = normalize_messages(messages)
155
+ chat.with_tools(tools.map { |tool| normalized_tool(tool) }) unless tools.empty?
156
+ chat.with_headers(candidate[:headers] || {})
157
+ chat.with_max_output_tokens(max_output_tokens) if max_output_tokens
158
+ chat.with_temperature(temperature) unless temperature.nil?
159
+ end
160
+
161
+ def generate(chat, stream, cancellation)
162
+ return generate_without_events(chat, cancellation) unless stream
163
+
164
+ state = StreamState.new
165
+ chat.generate do |chunk|
166
+ if cancelled?(cancellation)
167
+ chat.cancel
168
+ raise RubyLLM::CancelledError
169
+ end
170
+
171
+ stream_events(chunk, state).each { |event| yield event }
172
+ end
173
+ end
174
+
175
+ def generate_without_events(chat, cancellation)
176
+ raise RubyLLM::CancelledError if cancelled?(cancellation)
177
+
178
+ chat.generate
179
+ end
180
+
181
+ def cancelled?(token)
182
+ return false unless token
183
+
184
+ token.respond_to?(:cancelled?) ? token.cancelled? : token.call
185
+ end
186
+
187
+ def normalize_messages(messages)
188
+ stable_to_provider = tool_id_map(messages)
189
+ messages.map do |message|
190
+ role = message.fetch(:role).to_sym
191
+ normalized = {role: role, content: text_content(message[:content])}
192
+ normalized[:tool_calls] = normalize_input_tool_calls(message[:tool_calls]) if message[:tool_calls]
193
+ normalized[:tool_call_id] = stable_to_provider.fetch(message[:tool_call_id], message[:tool_call_id]) if role == :tool
194
+ normalized
195
+ end
196
+ end
197
+
198
+ def tool_id_map(messages)
199
+ messages.each_with_object({}) do |message, ids|
200
+ Array(message[:tool_calls]).each do |call|
201
+ ids[call[:id]] = call[:provider_id] || call[:id]
202
+ end
203
+ end
204
+ end
205
+
206
+ def normalize_input_tool_calls(tool_calls)
207
+ Array(tool_calls).to_h do |call|
208
+ provider_id = call[:provider_id] || call[:id]
209
+ arguments = JSON.parse(call.fetch(:arguments_json, "{}"))
210
+ [provider_id, {id: provider_id, name: call[:name], arguments: arguments}]
211
+ end
212
+ end
213
+
214
+ def text_content(content)
215
+ return content if content.is_a?(String) || content.nil?
216
+
217
+ Array(content).map do |part|
218
+ unless part[:type]&.to_sym == :text
219
+ raise UnsupportedOptionError, "unsupported content type: #{part[:type]}"
220
+ end
221
+
222
+ part[:text].to_s
223
+ end.join
224
+ end
225
+
226
+ def normalized_tool(tool)
227
+ definition = tool.transform_keys(&:to_sym)
228
+ name = definition.fetch(:name).to_s
229
+ Class.new(RubyLLM::Tool).tap do |klass|
230
+ klass.define_singleton_method(:tool_name) { name }
231
+ klass.description(definition[:description].to_s)
232
+ klass.parameters(definition[:input_schema] || definition[:parameters] || {type: "object", properties: {}})
233
+ klass.define_method(:execute) { |**| raise "AgentHarness transports never execute tools" }
234
+ end.new
235
+ end
236
+
237
+ def stream_events(chunk, state)
238
+ [text_event(chunk), *tool_call_events(chunk, state), usage_event(chunk, state)].compact
239
+ end
240
+
241
+ def text_event(chunk)
242
+ {type: :text_delta, content: chunk.content} unless chunk.content.nil? || chunk.content.empty?
243
+ end
244
+
245
+ def tool_call_events(chunk, state)
246
+ return [] unless chunk.tool_calls
247
+
248
+ chunk.tool_calls.flat_map { |stream_key, call| call_events(stream_key, call, state) }
249
+ end
250
+
251
+ # Continuation chunks have a nil id and carry the provider's raw JSON
252
+ # fragment as arguments; they join the call that started their key.
253
+ def call_events(stream_key, call, state)
254
+ provider_id = call.id || state.provider_id(stream_key)
255
+ return [] unless provider_id
256
+
257
+ events = []
258
+ if state.start(stream_key, provider_id)
259
+ events << {type: :tool_call_started, provider_id: provider_id, name: call.name}
260
+ end
261
+ fragment = argument_fragment(call.arguments)
262
+ events << {type: :tool_call_delta, provider_id: provider_id, arguments_json: fragment} if fragment
263
+ events
264
+ end
265
+
266
+ # Deltas stay appendable JSON text; a start chunk may instead carry a
267
+ # complete parsed Hash, which becomes JSON once.
268
+ def argument_fragment(arguments)
269
+ case arguments
270
+ when String then arguments.empty? ? nil : arguments
271
+ when Hash then arguments.empty? ? nil : JSON.generate(arguments)
272
+ end
273
+ end
274
+
275
+ def usage_event(chunk, state)
276
+ return unless cumulative_usage_changed?(chunk, state)
277
+
278
+ {type: :usage_updated, **state.usage}
279
+ end
280
+
281
+ def cumulative_usage_changed?(chunk, state)
282
+ tokens = chunk.tokens
283
+ return false unless tokens
284
+
285
+ changed = false
286
+ if tokens.input && tokens.input != state.input_tokens
287
+ state.input_tokens = tokens.input
288
+ changed = true
289
+ end
290
+ if tokens.output && tokens.output != state.output_tokens
291
+ state.output_tokens = tokens.output
292
+ changed = true
293
+ end
294
+ changed
295
+ end
296
+
297
+ def emit_completed_tool_calls(response)
298
+ Array(response.tool_calls&.values).each do |call|
299
+ yield(type: :tool_call_completed, **normalize_tool_call(call))
300
+ end
301
+ end
302
+
303
+ def normalize_response(response)
304
+ {
305
+ content: response.content || "",
306
+ model: response.model,
307
+ finish_reason: response.finish_reason,
308
+ usage: normalize_usage(response.tokens),
309
+ tool_calls: Array(response.tool_calls&.values).map { |call| normalize_tool_call(call) }
310
+ }
311
+ end
312
+
313
+ def normalize_tool_call(call)
314
+ {provider_id: call.id, name: call.name, arguments_json: JSON.generate(call.arguments || {})}
315
+ end
316
+
317
+ def normalize_usage(tokens)
318
+ return unless tokens&.to_h&.any?
319
+
320
+ tokens.to_h.merge(total_tokens: (tokens.input && tokens.output) ? tokens.input + tokens.output : nil)
321
+ end
322
+ end
323
+ end
324
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.40.0"
4
+ VERSION = "0.42.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -471,6 +471,8 @@ 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"
475
+ require_relative "agent_harness/api/chat_transport"
474
476
  require_relative "agent_harness/quota_status"
475
477
  require_relative "agent_harness/authentication"
476
478
  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.40.0
4
+ version: 0.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -135,6 +135,9 @@ files:
135
135
  - json-2.18.1.gem
136
136
  - lib/agent-harness.rb
137
137
  - lib/agent_harness.rb
138
+ - lib/agent_harness/api/attempt_report.rb
139
+ - lib/agent_harness/api/chat_transport.rb
140
+ - lib/agent_harness/api/ruby_llm_chat_adapter.rb
138
141
  - lib/agent_harness/authentication.rb
139
142
  - lib/agent_harness/command_executor.rb
140
143
  - lib/agent_harness/configuration.rb