agent-harness 0.40.0 → 0.41.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: c4406422d8b90ec6406bb4cdcc6e1d7496b708f43aad306d258c2570ed4f710e
4
+ data.tar.gz: cd23fe2f7d4b05e2ac18ac9aa2a631a0b292bc2703ae092f4f55a1ca6f3febbb
5
5
  SHA512:
6
- metadata.gz: 130a2897522e8b1aa73b2a9cf6094bd24b3b529696445e84356a78faa02540e0c4c2677602e306fca60e1f1a3040e48c04b3ff0a9ca28f144a24de6fc3fa5887
7
- data.tar.gz: 647ab39ee27728d28a3441ccbbd84edd3521e7e434a120ac236e411849b22aa431ae7fc6dbe499ec8c525aee6159c9fdba42a5ee8ff7ec329281dd7ea3a4ad33
6
+ metadata.gz: efb5e61347d70b28e03bb0cd2843429928843147bf8f196d1292af97be97c8ec67ca4ef81e6f169562d1fac5e88e277b06d07a7cd44bf3d85a772effb2c46051
7
+ data.tar.gz: 7c5fb37c7e14edbc3da68f74dc3f1a6d10e04630e70509a79bd04b9ebbfde2397d6a4753e288dd33d962efae79003728d30cdb0ae113d1bd4864de5995daa112
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.40.0"
2
+ ".": "0.41.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@
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.41.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.40.0...agent-harness/v0.41.0) (2026-09-25)
9
+
10
+
11
+ ### Features
12
+
13
+ * 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))
14
+
8
15
  ## [0.40.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.39.0...agent-harness/v0.40.0) (2026-09-25)
9
16
 
10
17
 
@@ -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 capability described in "Shipped normalized chat surface" below is now
12
+ implemented. 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:
@@ -0,0 +1,415 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "ruby_llm"
5
+ require_relative "ruby_llm_chat_adapter"
6
+
7
+ module AgentHarness
8
+ module Api
9
+ # Executes one normalized chat response without running application tools.
10
+ class ChatTransport
11
+ RESERVED_HEADERS = {
12
+ anthropic: %w[x-api-key anthropic-version],
13
+ openai: %w[authorization]
14
+ }.freeze
15
+ PROTOCOLS = {
16
+ anthropic: %i[messages],
17
+ openai: %i[responses chat_completions]
18
+ }.freeze
19
+ DEFAULT_RETRY = {max_attempts: 1, base_delay_seconds: 0, max_delay_seconds: 0}.freeze
20
+
21
+ # Raised when the caller's observer fails. Observer bugs stay outside
22
+ # provider error classification, abort the in-flight request, and are
23
+ # never re-invoked with a synthetic terminal event.
24
+ class ObserverError < StandardError
25
+ end
26
+
27
+ def initialize(adapter: RubyLlmChatAdapter.new, id_generator: -> { SecureRandom.uuid }, sleeper: Kernel.method(:sleep))
28
+ @adapter = adapter
29
+ @id_generator = id_generator
30
+ @sleeper = sleeper
31
+ end
32
+
33
+ def call(request, observer: nil, &on_event)
34
+ execution = Execution.new(request, adapter: @adapter, id_generator: @id_generator,
35
+ sleeper: @sleeper, observer: observer || on_event)
36
+ execution.call
37
+ end
38
+
39
+ # Holds mutable state for exactly one request, keeping the transport reusable.
40
+ class Execution
41
+ def initialize(request, adapter:, id_generator:, sleeper:, observer:)
42
+ @request = symbolize(request)
43
+ @adapter = adapter
44
+ @id_generator = id_generator
45
+ @sleeper = sleeper
46
+ @observer = observer
47
+ @attempts = []
48
+ @sequence = 0
49
+ @partial_content = +""
50
+ @stream_tool_calls = {}
51
+ @tool_ids = {}
52
+ validate!
53
+ end
54
+
55
+ def call
56
+ return cancelled_result unless active?
57
+
58
+ candidates.each_with_index do |candidate, candidate_index|
59
+ result = attempt_candidate(candidate, candidate_index)
60
+ return result if terminal?(result)
61
+ end
62
+ failed_result(@last_error, @last_candidate)
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :request, :attempts
68
+
69
+ def attempt_candidate(candidate, candidate_index)
70
+ loop do
71
+ return cancelled_result(candidate) unless active?
72
+ return failed_result(@last_error, candidate) if attempts.length >= retry_config[:max_attempts]
73
+
74
+ result = perform_attempt(candidate)
75
+ return result if result[:status] == :succeeded || result[:status] == :partial
76
+ return cancelled_result(candidate) unless active?
77
+
78
+ @last_error = result[:error]
79
+ @last_candidate = candidate
80
+ return result if select_fallback(result, candidate_index)
81
+ return result unless retryable?(result)
82
+
83
+ backoff
84
+ end
85
+ end
86
+
87
+ def perform_attempt(candidate)
88
+ attempt_id = @id_generator.call
89
+ started_at = Time.now.utc
90
+ emitted = false
91
+ streamed_usage = nil
92
+ emit(:response_started, attempt_id:, candidate: candidate_identity(candidate))
93
+
94
+ adapter_result = @adapter.call(
95
+ candidate: candidate,
96
+ messages: request[:messages],
97
+ tools: request[:tools] || [],
98
+ max_output_tokens: request[:max_output_tokens],
99
+ temperature: request[:temperature],
100
+ stream: request[:stream] == true,
101
+ timeout: request[:timeout],
102
+ cancellation: request[:cancellation]
103
+ ) do |event|
104
+ event = normalize_stream_event(event)
105
+ emitted = true if output_event?(event)
106
+ streamed_usage = event.except(:type) if event[:type] == :usage_updated
107
+ accumulate(event)
108
+ emit(event.fetch(:type), attempt_id:, **event.except(:type))
109
+ end
110
+ raise RubyLLM::CancelledError unless active?
111
+
112
+ success(candidate, attempt_id, started_at, adapter_result)
113
+ rescue ObserverError
114
+ raise
115
+ rescue => error
116
+ failure(candidate, attempt_id, started_at, classify(error), partial: emitted, usage: streamed_usage)
117
+ end
118
+
119
+ def success(candidate, attempt_id, started_at, adapter_result)
120
+ attempts << attempt_report(candidate, attempt_id, started_at, :succeeded, usage: adapter_result[:usage])
121
+ result = base_result(candidate).merge(
122
+ status: :succeeded,
123
+ content: adapter_result[:content] || "",
124
+ tool_calls: normalize_tool_calls(adapter_result[:tool_calls]),
125
+ finish_reason: adapter_result[:finish_reason],
126
+ usage: aggregate_usage,
127
+ error: nil
128
+ )
129
+ emit(:response_completed, attempt_id:, result: result)
130
+ result
131
+ end
132
+
133
+ def failure(candidate, attempt_id, started_at, error, partial:, usage:)
134
+ status = failure_status(error, partial)
135
+ error = error.merge(retryable: false) if partial
136
+ attempts << attempt_report(candidate, attempt_id, started_at, status, error: error, usage: usage)
137
+ result = base_result(candidate).merge(
138
+ status: status,
139
+ content: partial ? @partial_content.dup : "",
140
+ tool_calls: partial_tool_calls,
141
+ finish_reason: nil,
142
+ usage: aggregate_usage,
143
+ error: error
144
+ )
145
+ emit((status == :cancelled) ? :response_cancelled : :response_failed, attempt_id:, result: result)
146
+ result
147
+ end
148
+
149
+ def fallback_allowed?(result, candidate_index)
150
+ return false if result[:status] == :partial
151
+ return false unless fallback_categories.include?(result.dig(:error, :category))
152
+ return false unless candidates[candidate_index + 1]
153
+
154
+ true
155
+ end
156
+
157
+ def select_fallback(result, candidate_index)
158
+ return false unless fallback_allowed?(result, candidate_index)
159
+
160
+ next_candidate = candidates[candidate_index + 1]
161
+ emit(:fallback_selected, attempt_id: attempts.last[:attempt_id],
162
+ from: candidate_identity(candidates[candidate_index]),
163
+ to: candidate_identity(next_candidate), error: result[:error])
164
+ true
165
+ end
166
+
167
+ def retryable?(result)
168
+ result.dig(:error, :retryable) && attempts.length < retry_config[:max_attempts]
169
+ end
170
+
171
+ def terminal?(result)
172
+ return true if %i[succeeded partial cancelled].include?(result[:status])
173
+ return true unless fallback_categories.include?(result.dig(:error, :category))
174
+
175
+ attempts.length >= retry_config[:max_attempts]
176
+ end
177
+
178
+ def backoff
179
+ exponent = [attempts.length - 1, 0].max
180
+ delay = retry_config[:base_delay_seconds] * (2**exponent)
181
+ cap = retry_config[:max_delay_seconds]
182
+ remaining = cap&.positive? ? [delay, cap].min : delay
183
+ while remaining.positive? && active?
184
+ interval = [remaining, 0.05].min
185
+ @sleeper.call(interval)
186
+ remaining -= interval
187
+ end
188
+ end
189
+
190
+ def emit(type, attempt_id:, **payload)
191
+ @sequence += 1
192
+ event = payload.merge(type: type, request_id: request[:request_id], attempt_id: attempt_id, sequence: @sequence)
193
+ deliver(event) if @observer
194
+ end
195
+
196
+ # Observer exceptions are caller bugs, not provider failures: they
197
+ # abort the in-flight request and surface directly instead of being
198
+ # classified or retried as provider errors.
199
+ def deliver(event)
200
+ @observer.respond_to?(:on_chat_event) ? @observer.on_chat_event(event) : @observer.call(event)
201
+ rescue => error
202
+ raise ObserverError, "chat observer failed: #{error.class} #{error.message}", error.backtrace
203
+ end
204
+
205
+ def accumulate(event)
206
+ @partial_content << event[:content].to_s if event[:type] == :text_delta
207
+ return unless event[:id]
208
+
209
+ call = (@stream_tool_calls[event[:id]] ||= {
210
+ id: event[:id], provider_id: event[:provider_id], name: event[:name],
211
+ arguments_json: +"", status: :incomplete
212
+ })
213
+ call[:arguments_json] << event[:arguments_json].to_s if event[:type] == :tool_call_delta
214
+ if event[:type] == :tool_call_completed
215
+ call[:arguments_json] = event[:arguments_json]
216
+ call[:status] = :completed
217
+ end
218
+ end
219
+
220
+ def output_event?(event)
221
+ %i[text_delta tool_call_started tool_call_delta tool_call_completed].include?(event[:type])
222
+ end
223
+
224
+ def partial_tool_calls
225
+ @stream_tool_calls.values.map(&:dup)
226
+ end
227
+
228
+ def normalize_tool_calls(tool_calls)
229
+ Array(tool_calls).map do |call|
230
+ call.merge(id: tool_id(call[:provider_id]), status: :completed)
231
+ end
232
+ end
233
+
234
+ def normalize_stream_event(event)
235
+ return event unless event[:provider_id]
236
+
237
+ event.merge(id: tool_id(event[:provider_id]))
238
+ end
239
+
240
+ def tool_id(provider_id)
241
+ @tool_ids[provider_id] ||= @id_generator.call
242
+ end
243
+
244
+ def base_result(candidate)
245
+ {
246
+ request_id: request[:request_id],
247
+ provider: candidate&.dig(:provider),
248
+ model: candidate&.dig(:model),
249
+ protocol: candidate&.dig(:protocol),
250
+ authentication_mode: candidate&.dig(:authentication_mode),
251
+ parsed: nil,
252
+ attempts: attempts.dup,
253
+ provider_request_id: nil
254
+ }
255
+ end
256
+
257
+ def failed_result(error, candidate)
258
+ base_result(candidate).merge(status: :failed, content: "", tool_calls: [], finish_reason: nil,
259
+ usage: aggregate_usage, error: error)
260
+ end
261
+
262
+ def cancelled_result(candidate = candidates.first)
263
+ error = {category: :cancelled, code: :cancelled, retryable: false, message: "Request cancelled"}
264
+ base_result(candidate).merge(status: :cancelled, content: "", tool_calls: [], finish_reason: nil,
265
+ usage: aggregate_usage, error: error)
266
+ end
267
+
268
+ def attempt_report(candidate, attempt_id, started_at, status, error: nil, usage: nil)
269
+ {
270
+ attempt_id: attempt_id,
271
+ request_id: request[:request_id],
272
+ number: attempts.length + 1,
273
+ provider: candidate[:provider],
274
+ model: candidate[:model],
275
+ status: status,
276
+ started_at: started_at.iso8601(6),
277
+ finished_at: Time.now.utc.iso8601(6),
278
+ usage: usage,
279
+ cost: nil,
280
+ provider_reported: false,
281
+ error: error
282
+ }
283
+ end
284
+
285
+ def aggregate_usage
286
+ reports = attempts.filter_map { |attempt| attempt[:usage] }
287
+ return if reports.empty?
288
+
289
+ %i[input_tokens output_tokens total_tokens].to_h do |key|
290
+ values = reports.filter_map { |usage| usage[key] }
291
+ [key, values.empty? ? nil : values.sum]
292
+ end
293
+ end
294
+
295
+ def classify(error)
296
+ ErrorClassifier.call(error)
297
+ end
298
+
299
+ def failure_status(error, partial)
300
+ return :cancelled if error[:category] == :cancelled
301
+ return :partial if partial
302
+
303
+ :failed
304
+ end
305
+
306
+ def candidates = request[:candidates]
307
+ def retry_config = @retry_config ||= DEFAULT_RETRY.merge(request[:retry] || {})
308
+ def fallback_categories = Array(request.dig(:fallback, :on_error_categories)).map(&:to_sym)
309
+
310
+ def active?
311
+ token = request[:cancellation]
312
+ return true unless token
313
+
314
+ cancelled = token.respond_to?(:cancelled?) ? token.cancelled? : token.call
315
+ !cancelled
316
+ end
317
+
318
+ def validate!
319
+ raise ArgumentError, "operation must be :chat" unless request[:operation]&.to_sym == :chat
320
+ raise ArgumentError, "request_id is required" if request[:request_id].to_s.empty?
321
+ raise ArgumentError, "candidates must not be empty" if !request[:candidates].is_a?(Array) || request[:candidates].empty?
322
+ raise ArgumentError, "messages must be an array" unless request[:messages].is_a?(Array)
323
+ validate_attempt_limit!
324
+ candidates.each { |candidate| validate_candidate!(candidate) }
325
+ end
326
+
327
+ def validate_attempt_limit!
328
+ limit = retry_config[:max_attempts]
329
+ raise ArgumentError, "retry.max_attempts must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
330
+ end
331
+
332
+ def validate_candidate!(candidate)
333
+ candidate.replace(symbolize(candidate))
334
+ %i[provider model protocol authentication_mode credentials].each do |key|
335
+ raise ArgumentError, "candidate.#{key} is required" if candidate[key].nil?
336
+ end
337
+ raise ArgumentError, "only api_key authentication is supported" unless candidate[:authentication_mode].to_sym == :api_key
338
+ supported_protocols = PROTOCOLS[candidate[:provider].to_sym]
339
+ unless supported_protocols&.include?(candidate[:protocol].to_sym)
340
+ raise ArgumentError, "unsupported provider/protocol combination"
341
+ end
342
+
343
+ headers = candidate[:headers] || {}
344
+ reserved_names = RESERVED_HEADERS.fetch(candidate[:provider].to_sym, RESERVED_HEADERS.values.flatten)
345
+ reserved = headers.keys.map { |key| key.to_s.downcase } & reserved_names
346
+ raise ArgumentError, "reserved header override: #{reserved.first}" if reserved.any?
347
+ end
348
+
349
+ def candidate_identity(candidate)
350
+ candidate.slice(:provider, :model, :protocol, :authentication_mode)
351
+ end
352
+
353
+ def symbolize(value)
354
+ if value.is_a?(Hash)
355
+ return value.each_with_object({}) do |(key, child), normalized|
356
+ symbol = key.to_sym
357
+ normalized[symbol] = (symbol == :headers) ? child.dup : symbolize(child)
358
+ end
359
+ end
360
+ return value.map { |child| symbolize(child) } if value.is_a?(Array)
361
+
362
+ value
363
+ end
364
+ end
365
+ end
366
+
367
+ module ErrorClassifier
368
+ TRANSIENT = {
369
+ Faraday::TimeoutError => :timeout,
370
+ Faraday::ConnectionFailed => :connection_failed,
371
+ Faraday::SSLError => :connection_failed,
372
+ RubyLLM::RateLimitError => :rate_limited,
373
+ RubyLLM::ServerError => :server_error,
374
+ RubyLLM::ServiceUnavailableError => :service_unavailable,
375
+ RubyLLM::OverloadedError => :overloaded
376
+ }.freeze
377
+
378
+ NON_RETRYABLE = {
379
+ RubyLlmChatAdapter::MissingCredentialError => [:authentication, :invalid_credential],
380
+ RubyLLM::UnauthorizedError => [:authentication, :invalid_credential],
381
+ RubyLLM::ForbiddenError => [:authorization, :permission_denied],
382
+ RubyLLM::PaymentRequiredError => [:billing, :billing_unavailable],
383
+ RubyLLM::ContextLengthExceededError => [:context_length, :context_length_exceeded],
384
+ RubyLLM::BadRequestError => [:invalid_request, :invalid_request],
385
+ RubyLlmChatAdapter::UnsupportedOptionError => [:unsupported, :unsupported_capability],
386
+ RubyLLM::UnsupportedServerToolError => [:unsupported, :unsupported_capability],
387
+ RubyLLM::ToolCallParseError => [:invalid_response, :invalid_tool_arguments],
388
+ JSON::ParserError => [:invalid_response, :invalid_tool_arguments],
389
+ RubyLLM::ModelNotFoundError => [:configuration, :invalid_configuration],
390
+ RubyLLM::ConfigurationError => [:configuration, :invalid_configuration],
391
+ RubyLLM::CancelledError => [:cancelled, :cancelled]
392
+ }.freeze
393
+
394
+ def self.call(error)
395
+ transient = TRANSIENT.find { |klass, _| error.is_a?(klass) }
396
+ return payload(error, :transient, transient.last, true) if transient
397
+
398
+ permanent = NON_RETRYABLE.find { |klass, _| error.is_a?(klass) }
399
+ return payload(error, *permanent.last, false) if permanent
400
+
401
+ payload(error, :unknown, :unclassified_provider_error, false)
402
+ end
403
+
404
+ def self.payload(error, category, code, retryable)
405
+ {category: category, code: code, retryable: retryable, message: safe_message(category, code)}
406
+ end
407
+ private_class_method :payload
408
+
409
+ def self.safe_message(category, code)
410
+ "Chat request failed (#{category}/#{code})"
411
+ end
412
+ private_class_method :safe_message
413
+ end
414
+ end
415
+ end
@@ -0,0 +1,273 @@
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
+ OPENAI_UNSUPPLIED_CONFIG = %i[openai_organization_id openai_project_id openai_use_system_role].freeze
18
+
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)
25
+ emit_completed_tool_calls(response, &on_event) if stream
26
+ normalize_response(response)
27
+ end
28
+
29
+ # RubyLLM keys streamed tool-call chunks by a stream index while
30
+ # continuation chunks carry no call id, so correlation state must live
31
+ # between chunks. Also remembers the latest cumulative token counts so
32
+ # duplicate cumulative reports are not re-emitted.
33
+ class StreamState
34
+ attr_accessor :input_tokens, :output_tokens
35
+
36
+ def initialize
37
+ @provider_id_by_key = {}
38
+ @started_ids = {}
39
+ @latest_provider_id = nil
40
+ @input_tokens = nil
41
+ @output_tokens = nil
42
+ end
43
+
44
+ # Links a stream chunk key to its provider call id, returning true
45
+ # only the first time +provider_id+ is seen.
46
+ def start(stream_key, provider_id)
47
+ @provider_id_by_key[stream_key] = provider_id unless stream_key.nil?
48
+ @latest_provider_id = provider_id
49
+ return false if @started_ids.key?(provider_id)
50
+
51
+ @started_ids[provider_id] = true
52
+ end
53
+
54
+ def provider_id(stream_key)
55
+ stream_key.nil? ? @latest_provider_id : @provider_id_by_key[stream_key]
56
+ end
57
+
58
+ def usage
59
+ total = (input_tokens + output_tokens) if input_tokens && output_tokens
60
+ {input_tokens: input_tokens, output_tokens: output_tokens, total_tokens: total}
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def ruby_llm_protocol(candidate)
67
+ return :anthropic if candidate[:provider].to_sym == :anthropic && candidate[:protocol].to_sym == :messages
68
+
69
+ candidate[:protocol]
70
+ end
71
+
72
+ def build_context(candidate, timeout)
73
+ provider = candidate[:provider].to_sym
74
+ config_keys = PROVIDER_CONFIG[provider]
75
+ raise RubyLLM::ConfigurationError, "Unsupported chat provider: #{provider}" unless config_keys
76
+ raise MissingCredentialError, "API key is required" if candidate.dig(:credentials, :api_key).to_s.empty?
77
+
78
+ RubyLLM.context do |config|
79
+ config.public_send("#{config_keys[0]}=", candidate.dig(:credentials, :api_key))
80
+ config.public_send("#{config_keys[1]}=", candidate[:endpoint])
81
+ clear_unsupplied_openai_config(config) if provider == :openai
82
+ config.max_retries = 0
83
+ apply_timeout(config, timeout)
84
+ end
85
+ end
86
+
87
+ def clear_unsupplied_openai_config(config)
88
+ OPENAI_UNSUPPLIED_CONFIG.each { |key| config.public_send("#{key}=", nil) }
89
+ end
90
+
91
+ def apply_timeout(config, timeout)
92
+ if timeout&.dig(:connect_seconds)
93
+ raise UnsupportedOptionError, "RubyLLM does not support request-local connect timeouts"
94
+ end
95
+
96
+ seconds = timeout&.dig(:read_seconds)
97
+ config.request_timeout = seconds if seconds
98
+ end
99
+
100
+ def configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
101
+ chat.messages = normalize_messages(messages)
102
+ chat.with_tools(tools.map { |tool| normalized_tool(tool) }) unless tools.empty?
103
+ chat.with_headers(candidate[:headers] || {})
104
+ chat.with_max_output_tokens(max_output_tokens) if max_output_tokens
105
+ chat.with_temperature(temperature) unless temperature.nil?
106
+ end
107
+
108
+ def generate(chat, stream, cancellation)
109
+ return generate_without_events(chat, cancellation) unless stream
110
+
111
+ state = StreamState.new
112
+ chat.generate do |chunk|
113
+ if cancelled?(cancellation)
114
+ chat.cancel
115
+ raise RubyLLM::CancelledError
116
+ end
117
+
118
+ stream_events(chunk, state).each { |event| yield event }
119
+ end
120
+ end
121
+
122
+ def generate_without_events(chat, cancellation)
123
+ raise RubyLLM::CancelledError if cancelled?(cancellation)
124
+
125
+ chat.generate
126
+ end
127
+
128
+ def cancelled?(token)
129
+ return false unless token
130
+
131
+ token.respond_to?(:cancelled?) ? token.cancelled? : token.call
132
+ end
133
+
134
+ def normalize_messages(messages)
135
+ stable_to_provider = tool_id_map(messages)
136
+ messages.map do |message|
137
+ role = message.fetch(:role).to_sym
138
+ normalized = {role: role, content: text_content(message[:content])}
139
+ normalized[:tool_calls] = normalize_input_tool_calls(message[:tool_calls]) if message[:tool_calls]
140
+ normalized[:tool_call_id] = stable_to_provider.fetch(message[:tool_call_id], message[:tool_call_id]) if role == :tool
141
+ normalized
142
+ end
143
+ end
144
+
145
+ def tool_id_map(messages)
146
+ messages.each_with_object({}) do |message, ids|
147
+ Array(message[:tool_calls]).each do |call|
148
+ ids[call[:id]] = call[:provider_id] || call[:id]
149
+ end
150
+ end
151
+ end
152
+
153
+ def normalize_input_tool_calls(tool_calls)
154
+ Array(tool_calls).to_h do |call|
155
+ provider_id = call[:provider_id] || call[:id]
156
+ arguments = JSON.parse(call.fetch(:arguments_json, "{}"))
157
+ [provider_id, {id: provider_id, name: call[:name], arguments: arguments}]
158
+ end
159
+ end
160
+
161
+ def text_content(content)
162
+ return content if content.is_a?(String) || content.nil?
163
+
164
+ Array(content).map do |part|
165
+ unless part[:type]&.to_sym == :text
166
+ raise UnsupportedOptionError, "unsupported content type: #{part[:type]}"
167
+ end
168
+
169
+ part[:text].to_s
170
+ end.join
171
+ end
172
+
173
+ def normalized_tool(tool)
174
+ definition = tool.transform_keys(&:to_sym)
175
+ name = definition.fetch(:name).to_s
176
+ Class.new(RubyLLM::Tool).tap do |klass|
177
+ klass.define_singleton_method(:tool_name) { name }
178
+ klass.description(definition[:description].to_s)
179
+ klass.parameters(definition[:input_schema] || definition[:parameters] || {type: "object", properties: {}})
180
+ klass.define_method(:execute) { |**| raise "AgentHarness transports never execute tools" }
181
+ end.new
182
+ end
183
+
184
+ def stream_events(chunk, state)
185
+ [text_event(chunk), *tool_call_events(chunk, state), usage_event(chunk, state)].compact
186
+ end
187
+
188
+ def text_event(chunk)
189
+ {type: :text_delta, content: chunk.content} unless chunk.content.nil? || chunk.content.empty?
190
+ end
191
+
192
+ def tool_call_events(chunk, state)
193
+ return [] unless chunk.tool_calls
194
+
195
+ chunk.tool_calls.flat_map { |stream_key, call| call_events(stream_key, call, state) }
196
+ end
197
+
198
+ # Continuation chunks have a nil id and carry the provider's raw JSON
199
+ # fragment as arguments; they join the call that started their key.
200
+ def call_events(stream_key, call, state)
201
+ provider_id = call.id || state.provider_id(stream_key)
202
+ return [] unless provider_id
203
+
204
+ events = []
205
+ if state.start(stream_key, provider_id)
206
+ events << {type: :tool_call_started, provider_id: provider_id, name: call.name}
207
+ end
208
+ fragment = argument_fragment(call.arguments)
209
+ events << {type: :tool_call_delta, provider_id: provider_id, arguments_json: fragment} if fragment
210
+ events
211
+ end
212
+
213
+ # Deltas stay appendable JSON text; a start chunk may instead carry a
214
+ # complete parsed Hash, which becomes JSON once.
215
+ def argument_fragment(arguments)
216
+ case arguments
217
+ when String then arguments.empty? ? nil : arguments
218
+ when Hash then arguments.empty? ? nil : JSON.generate(arguments)
219
+ end
220
+ end
221
+
222
+ def usage_event(chunk, state)
223
+ return unless cumulative_usage_changed?(chunk, state)
224
+
225
+ {type: :usage_updated, **state.usage}
226
+ end
227
+
228
+ def cumulative_usage_changed?(chunk, state)
229
+ tokens = chunk.tokens
230
+ return false unless tokens
231
+
232
+ changed = false
233
+ if tokens.input && tokens.input != state.input_tokens
234
+ state.input_tokens = tokens.input
235
+ changed = true
236
+ end
237
+ if tokens.output && tokens.output != state.output_tokens
238
+ state.output_tokens = tokens.output
239
+ changed = true
240
+ end
241
+ changed
242
+ end
243
+
244
+ def emit_completed_tool_calls(response)
245
+ Array(response.tool_calls&.values).each do |call|
246
+ yield(type: :tool_call_completed, **normalize_tool_call(call))
247
+ end
248
+ end
249
+
250
+ def normalize_response(response)
251
+ {
252
+ content: response.content || "",
253
+ model: response.model,
254
+ finish_reason: response.finish_reason,
255
+ usage: normalize_usage(response.tokens),
256
+ tool_calls: Array(response.tool_calls&.values).map { |call| normalize_tool_call(call) }
257
+ }
258
+ end
259
+
260
+ def normalize_tool_call(call)
261
+ {provider_id: call.id, name: call.name, arguments_json: JSON.generate(call.arguments || {})}
262
+ end
263
+
264
+ def normalize_usage(tokens)
265
+ input = tokens&.input
266
+ output = tokens&.output
267
+ return unless input || output
268
+
269
+ {input_tokens: input, output_tokens: output, total_tokens: (input && output) ? input + output : nil}
270
+ end
271
+ end
272
+ end
273
+ 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.41.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -471,6 +471,7 @@ 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/chat_transport"
474
475
  require_relative "agent_harness/quota_status"
475
476
  require_relative "agent_harness/authentication"
476
477
  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.41.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -135,6 +135,8 @@ 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/chat_transport.rb
139
+ - lib/agent_harness/api/ruby_llm_chat_adapter.rb
138
140
  - lib/agent_harness/authentication.rb
139
141
  - lib/agent_harness/command_executor.rb
140
142
  - lib/agent_harness/configuration.rb