agent-harness 0.10.0 → 0.11.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.
@@ -181,6 +181,65 @@ module AgentHarness
181
181
  handle_error(e, prompt: prompt, options: options)
182
182
  end
183
183
 
184
+ # Send a multi-turn chat message via the provider's chat transport.
185
+ #
186
+ # Providers that support chat mode can accept either +conversation:+
187
+ # or +messages:+ as the conversation history payload.
188
+ #
189
+ # Structured streaming events are delivered through three channels:
190
+ # - +on_chat_chunk+ proc (keyword argument)
191
+ # - +observer+ object responding to +on_chat_chunk+
192
+ # - block (yield)
193
+ #
194
+ # When multiple receivers are provided, all receive every event.
195
+ #
196
+ # @param conversation [Array<Hash>, nil] message history
197
+ # @param messages [Array<Hash>, nil] alias for +conversation+
198
+ # @param tools [Array<Hash>, nil] tool/function definitions
199
+ # @param stream [Boolean] whether to stream the response
200
+ # @param on_chat_chunk [Proc, nil] callback for structured streaming events
201
+ # @param observer [#on_chat_chunk, nil] observer receiving streaming events
202
+ # @param options [Hash] additional options
203
+ # @yield [Hash] streaming chunks when stream: true
204
+ # @return [Response] the response
205
+ # @raise [ProviderError] if the provider does not support chat mode
206
+ def send_chat_message(conversation: nil, messages: nil, tools: nil, stream: false,
207
+ on_chat_chunk: nil, observer: nil, **options, &on_chunk)
208
+ unless supports_chat?
209
+ raise ProviderError, "#{name} does not support chat mode"
210
+ end
211
+
212
+ options = normalize_provider_runtime(options)
213
+ runtime = options[:provider_runtime]
214
+ conversation ||= messages
215
+ raise ArgumentError, "conversation or messages is required" unless conversation
216
+ tools = runtime.chat_tools if tools.nil? && runtime&.chat_tools
217
+
218
+ transport = resolve_chat_transport(options)
219
+ messages = format_messages_for_transport(conversation, transport)
220
+ transport_opts = chat_transport_options(runtime, options)
221
+ transport_opts[:on_chat_chunk] = on_chat_chunk if on_chat_chunk
222
+ transport_opts[:observer] = observer if observer
223
+
224
+ response = transport.chat(
225
+ messages: messages,
226
+ tools: tools,
227
+ stream: stream,
228
+ **transport_opts,
229
+ &on_chunk
230
+ )
231
+
232
+ track_tokens(response) if response.tokens
233
+ log_debug("send_chat_message_complete", duration: response.duration, tokens: response.tokens)
234
+
235
+ response
236
+ rescue ProviderError, AuthenticationError, RateLimitError, TimeoutError
237
+ raise
238
+ rescue => e
239
+ last_msg = conversation&.last || messages&.last
240
+ handle_error(e, prompt: (last_msg&.dig(:content) || last_msg&.dig("content")).to_s, options: options)
241
+ end
242
+
184
243
  # Provider name for display
185
244
  #
186
245
  # @return [String] display name
@@ -466,6 +525,89 @@ module AgentHarness
466
525
  end
467
526
  end
468
527
 
528
+ def resolve_chat_transport(options)
529
+ runtime = options[:provider_runtime]
530
+
531
+ # When the runtime specifies chat-specific overrides (base_url, api_key),
532
+ # build a fresh transport instead of reusing the memoized default.
533
+ if runtime && (runtime.chat_base_url || runtime.chat_api_key)
534
+ transport = build_runtime_chat_transport(runtime)
535
+ if transport
536
+ return transport
537
+ end
538
+ end
539
+
540
+ transport = chat_transport
541
+ raise ProviderError, "#{name} chat_transport returned nil" unless transport
542
+
543
+ transport
544
+ end
545
+
546
+ # Build a one-off chat transport from ProviderRuntime overrides.
547
+ #
548
+ # Subclasses that support chat must override this when the runtime
549
+ # carries chat_base_url or chat_api_key so those overrides are
550
+ # actually applied. The base implementation raises to surface the
551
+ # misconfiguration early rather than silently ignoring the overrides.
552
+ def build_runtime_chat_transport(_runtime)
553
+ raise ProviderError,
554
+ "#{name} does not support chat_base_url/chat_api_key overrides on ProviderRuntime"
555
+ end
556
+
557
+ def format_messages_for_transport(conversation, transport)
558
+ normalized = conversation.map { |msg| normalize_transport_message(msg) }
559
+ return normalized unless anthropic_transport?(transport)
560
+ return normalized unless anthropic_conversion_required?(normalized)
561
+
562
+ anthropic = anthropic_conversation(normalized)
563
+ system_messages = anthropic[:system] ? [{role: "system", content: anthropic[:system]}] : []
564
+
565
+ system_messages + anthropic[:messages]
566
+ end
567
+
568
+ def normalize_transport_message(message)
569
+ message.each_with_object({}) do |(key, value), memo|
570
+ memo[key.is_a?(String) ? key.to_sym : key] = value
571
+ end.tap do |normalized|
572
+ normalized[:role] = normalized[:role].to_s if normalized.key?(:role)
573
+ end
574
+ end
575
+
576
+ def anthropic_transport?(transport)
577
+ chat_transport_type == :anthropic || transport.is_a?(TextTransport)
578
+ end
579
+
580
+ def anthropic_conversion_required?(messages)
581
+ messages.any? do |msg|
582
+ msg[:role] == "tool" || msg.key?(:tool_calls)
583
+ end
584
+ end
585
+
586
+ def anthropic_conversation(messages)
587
+ conversation = Conversation.new
588
+
589
+ messages.each do |msg|
590
+ conversation.add_message(
591
+ msg.fetch(:role).to_sym,
592
+ msg[:content],
593
+ tool_calls: msg[:tool_calls],
594
+ tool_call_id: msg[:tool_call_id]
595
+ )
596
+ end
597
+
598
+ conversation.to_anthropic_messages
599
+ end
600
+
601
+ def chat_transport_options(runtime, options)
602
+ opts = {}
603
+ max_tok = options[:chat_max_tokens] || options[:max_tokens] || runtime&.chat_max_tokens
604
+ opts[:max_tokens] = max_tok if max_tok
605
+ model = runtime&.chat_model || runtime&.model
606
+ opts[:model] = model if model
607
+ opts[:temperature] = options[:temperature] if options[:temperature]
608
+ opts
609
+ end
610
+
469
611
  def log_debug(action, **context)
470
612
  @logger&.debug("[AgentHarness::#{self.class.provider_name}] #{action}: #{context.inspect}")
471
613
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require "json"
5
+ require "pathname"
5
6
 
6
7
  module AgentHarness
7
8
  module Providers
@@ -97,6 +98,10 @@ module AgentHarness
97
98
  ]
98
99
  end
99
100
 
101
+ def supports_chat?
102
+ true
103
+ end
104
+
100
105
  def smoke_test_contract
101
106
  SMOKE_TEST_CONTRACT
102
107
  end
@@ -194,6 +199,40 @@ module AgentHarness
194
199
  ["--resume", session_id]
195
200
  end
196
201
 
202
+ GITHUB_MODELS_BASE_URL = "https://models.inference.ai.azure.com"
203
+ CHAT_DEFAULT_MODEL = "gpt-4o"
204
+ CHAT_MODELS = %w[gpt-4o gpt-4o-mini gpt-4-turbo].freeze
205
+
206
+ def supports_chat?
207
+ true
208
+ end
209
+
210
+ def chat_models
211
+ CHAT_MODELS
212
+ end
213
+
214
+ def chat_transport
215
+ @chat_transport ||= OpenAICompatibleTransport.new(
216
+ base_url: GITHUB_MODELS_BASE_URL,
217
+ api_key: resolve_chat_api_key,
218
+ model: CHAT_DEFAULT_MODEL,
219
+ logger: @logger
220
+ )
221
+ end
222
+
223
+ def build_runtime_chat_transport(runtime)
224
+ OpenAICompatibleTransport.new(
225
+ base_url: runtime.chat_base_url || GITHUB_MODELS_BASE_URL,
226
+ api_key: runtime.chat_api_key || resolve_chat_api_key,
227
+ model: runtime.chat_model || runtime.model || CHAT_DEFAULT_MODEL,
228
+ logger: @logger
229
+ )
230
+ end
231
+
232
+ def chat_transport_type
233
+ :openai_compatible
234
+ end
235
+
197
236
  def auth_type
198
237
  :oauth
199
238
  end
@@ -801,6 +840,28 @@ module AgentHarness
801
840
  def hash_key_present?(value, key)
802
841
  value.is_a?(Hash) && value.key?(key)
803
842
  end
843
+
844
+ def resolve_chat_api_key
845
+ key = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"] || read_copilot_cli_access_token
846
+
847
+ if key.nil? || key.strip.empty?
848
+ raise AuthenticationError.new(
849
+ "Chat mode requires a GitHub token. Set GITHUB_TOKEN or GH_TOKEN, or authenticate the Copilot CLI.",
850
+ provider: :github_copilot
851
+ )
852
+ end
853
+
854
+ key.strip
855
+ end
856
+
857
+ def read_copilot_cli_access_token
858
+ path = Pathname.new(File.join(Dir.home, ".copilot-cli-access-token"))
859
+ return nil unless path.file?
860
+
861
+ path.read
862
+ rescue Errno::ENOENT, Errno::EACCES, IOError
863
+ nil
864
+ end
804
865
  end
805
866
  end
806
867
  end
@@ -25,13 +25,61 @@ module AgentHarness
25
25
  DEFAULT_MAX_TOKENS = 4096
26
26
  DEFAULT_TIMEOUT = 300
27
27
 
28
+ # @param base_url [String] Anthropic Messages API URL
28
29
  # @param api_key [String] Anthropic API key
29
30
  # @param logger [Logger, nil] optional logger
30
- def initialize(api_key:, logger: nil)
31
+ def initialize(api_key:, base_url: ANTHROPIC_API_URL, logger: nil)
32
+ @base_url = base_url
31
33
  @api_key = api_key
32
34
  @logger = logger
33
35
  end
34
36
 
37
+ # Send a multi-turn chat completion request via the Anthropic Messages API.
38
+ #
39
+ # @param messages [Array<Hash>] conversation messages with :role and :content
40
+ # @param tools [Array<Hash>, nil] tool definitions (Anthropic tool format)
41
+ # @param stream [Boolean] whether to stream the response
42
+ # @param max_tokens [Integer, nil] maximum tokens in the response
43
+ # @param temperature [Float, nil] sampling temperature
44
+ # @yield [Hash] streaming chunks when stream: true
45
+ # @return [Response] the response
46
+ def chat(messages:, tools: nil, stream: false, max_tokens: nil, temperature: nil,
47
+ model: nil, on_chat_chunk: nil, observer: nil, &on_chunk)
48
+ model ||= DEFAULT_MODEL
49
+ timeout = DEFAULT_TIMEOUT
50
+ max_tokens ||= DEFAULT_MAX_TOKENS
51
+
52
+ uri = URI(@base_url)
53
+
54
+ system_messages = messages.select { |m| m[:role] == "system" || m["role"] == "system" }
55
+ non_system = messages.reject { |m| m[:role] == "system" || m["role"] == "system" }
56
+ has_stream_receiver = on_chunk || on_chat_chunk || observer_responds_to?(observer, :on_chat_chunk)
57
+ request_stream = stream && has_stream_receiver
58
+
59
+ body = build_chat_request_body(
60
+ model: model,
61
+ max_tokens: max_tokens,
62
+ messages: non_system,
63
+ system_messages: system_messages,
64
+ tools: tools,
65
+ temperature: temperature,
66
+ stream: request_stream
67
+ )
68
+
69
+ start_time = Time.now
70
+
71
+ if request_stream
72
+ combined = build_chat_chunk_callback(on_chunk, on_chat_chunk, observer)
73
+ result = make_streaming_request(uri, body, timeout: timeout, &combined)
74
+ duration = Time.now - start_time
75
+ build_streaming_response(result, duration: duration, model: model)
76
+ else
77
+ http_response = make_request(uri, body, timeout: timeout)
78
+ duration = Time.now - start_time
79
+ parse_response(http_response, duration: duration, model: model)
80
+ end
81
+ end
82
+
35
83
  # Send a text-only message via the Anthropic Messages API.
36
84
  #
37
85
  # @param prompt [String] the user prompt
@@ -48,7 +96,7 @@ module AgentHarness
48
96
  timeout ||= DEFAULT_TIMEOUT
49
97
  max_tokens ||= DEFAULT_MAX_TOKENS
50
98
 
51
- uri = URI(ANTHROPIC_API_URL)
99
+ uri = URI(@base_url)
52
100
  body = {
53
101
  model: model,
54
102
  max_tokens: max_tokens,
@@ -64,25 +112,160 @@ module AgentHarness
64
112
 
65
113
  private
66
114
 
115
+ def build_chat_request_body(model:, max_tokens:, messages:, system_messages:, tools:, temperature:, stream:)
116
+ body = {
117
+ model: model,
118
+ max_tokens: max_tokens,
119
+ messages: messages.map { |m| {role: m[:role] || m["role"], content: m[:content] || m["content"]} }
120
+ }
121
+ body[:system] = system_messages.map { |m| m[:content] || m["content"] }.join("\n") if system_messages.any?
122
+ body[:tools] = tools if tools
123
+ body[:temperature] = temperature if temperature
124
+ body[:stream] = true if stream
125
+ body
126
+ end
127
+
67
128
  def make_request(uri, body, timeout:)
129
+ http = build_http(uri, timeout: timeout)
130
+ request = build_post_request(uri, body)
131
+
132
+ @logger&.debug("[AgentHarness::TextTransport] POST #{uri} model=#{body[:model]}")
133
+
134
+ http.request(request)
135
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
136
+ raise TimeoutError.new(e.message, original_error: e)
137
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, IOError => e
138
+ raise ProviderError.new("HTTP connection error: #{e.message}", original_error: e)
139
+ end
140
+
141
+ def make_streaming_request(uri, body, timeout:, &on_chunk)
142
+ http = build_http(uri, timeout: timeout)
143
+ request = build_post_request(uri, body)
144
+
145
+ @logger&.debug("[AgentHarness::TextTransport] POST #{uri} model=#{body[:model]} stream=true")
146
+
147
+ accumulated = {content: +"", model: nil, usage: nil, tool_calls: []}
148
+
149
+ http.request(request) do |http_response|
150
+ status_code = http_response.code.to_i
151
+ unless status_code == 200
152
+ response_body = http_response.read_body
153
+ handle_error_response_raw(response_body, status_code)
154
+ end
155
+
156
+ parse_sse_stream(http_response, accumulated, &on_chunk)
157
+ end
158
+
159
+ accumulated
160
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
161
+ raise TimeoutError.new(e.message, original_error: e)
162
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, IOError => e
163
+ raise ProviderError.new("HTTP connection error: #{e.message}", original_error: e)
164
+ end
165
+
166
+ def build_http(uri, timeout:)
68
167
  http = Net::HTTP.new(uri.host, uri.port)
69
- http.use_ssl = true
168
+ http.use_ssl = (uri.scheme == "https")
70
169
  http.open_timeout = [timeout, 30].min
71
170
  http.read_timeout = timeout
171
+ http
172
+ end
72
173
 
174
+ def build_post_request(uri, body)
73
175
  request = Net::HTTP::Post.new(uri)
74
176
  request["Content-Type"] = "application/json"
75
177
  request["x-api-key"] = @api_key
76
178
  request["anthropic-version"] = ANTHROPIC_API_VERSION
77
179
  request.body = JSON.generate(body)
180
+ request
181
+ end
78
182
 
79
- @logger&.debug("[AgentHarness::TextTransport] POST #{uri} model=#{body[:model]}")
183
+ def parse_sse_stream(http_response, accumulated, &on_chunk)
184
+ buffer = +""
185
+ event_name = nil
186
+ data_lines = []
80
187
 
81
- http.request(request)
82
- rescue Net::OpenTimeout, Net::ReadTimeout => e
83
- raise TimeoutError.new(e.message, original_error: e)
84
- rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, IOError => e
85
- raise ProviderError.new("HTTP connection error: #{e.message}", original_error: e)
188
+ http_response.read_body do |chunk|
189
+ buffer << chunk.delete("\r")
190
+
191
+ while (line_end = buffer.index("\n"))
192
+ line = buffer.slice!(0, line_end + 1).chomp("\n")
193
+
194
+ if line.empty?
195
+ process_sse_event(event_name, data_lines.join("\n"), accumulated, &on_chunk)
196
+ event_name = nil
197
+ data_lines = []
198
+ next
199
+ end
200
+
201
+ if line.start_with?("event:")
202
+ event_name = line[6..].strip
203
+ elsif line.start_with?("data:")
204
+ data_lines << line[5..].lstrip
205
+ end
206
+ end
207
+ end
208
+
209
+ process_sse_event(event_name, data_lines.join("\n"), accumulated, &on_chunk) unless data_lines.empty?
210
+ end
211
+
212
+ def process_sse_event(event_name, raw_data, accumulated, &on_chunk)
213
+ return if raw_data.nil? || raw_data.empty?
214
+ return if event_name == "ping"
215
+
216
+ payload = JSON.parse(raw_data)
217
+ type = payload["type"] || event_name
218
+
219
+ case type
220
+ when "message_start"
221
+ message = payload["message"] || {}
222
+ accumulated[:model] ||= message["model"]
223
+ merge_usage!(accumulated, message["usage"])
224
+ when "content_block_start"
225
+ process_content_block_start(payload, accumulated, &on_chunk)
226
+ when "content_block_delta"
227
+ process_content_block_delta(payload, accumulated, &on_chunk)
228
+ when "content_block_stop"
229
+ process_content_block_stop(payload, accumulated, &on_chunk)
230
+ when "message_delta"
231
+ merge_usage!(accumulated, payload["usage"])
232
+ when "message_stop"
233
+ emit_usage_and_done(accumulated, &on_chunk)
234
+ when "error"
235
+ message = payload.dig("error", "message") || payload.dig("error", "type") || raw_data
236
+ raise ProviderError, message
237
+ end
238
+ rescue JSON::ParserError => e
239
+ @logger&.warn("[AgentHarness::TextTransport] Skipping malformed SSE event: #{e.message}")
240
+ end
241
+
242
+ def emit_text_delta(text, accumulated, &on_chunk)
243
+ return if text.nil? || text.empty?
244
+
245
+ accumulated[:content] << text
246
+ on_chunk.call({type: :text, content: text})
247
+ end
248
+
249
+ def merge_usage!(accumulated, usage)
250
+ return unless usage
251
+
252
+ current = accumulated[:usage] || {input: 0, output: 0, total: 0}
253
+ current[:input] = usage["input_tokens"] unless usage["input_tokens"].nil?
254
+ current[:output] = usage["output_tokens"] unless usage["output_tokens"].nil?
255
+ current[:total] = current[:input].to_i + current[:output].to_i
256
+ accumulated[:usage] = current
257
+ end
258
+
259
+ def emit_usage_and_done(accumulated, &on_chunk)
260
+ usage = accumulated[:usage]
261
+ if usage
262
+ on_chunk.call({
263
+ type: :usage,
264
+ input_tokens: usage[:input],
265
+ output_tokens: usage[:output]
266
+ })
267
+ end
268
+ on_chunk.call({type: :done})
86
269
  end
87
270
 
88
271
  def parse_response(http_response, duration:, model:)
@@ -95,6 +278,10 @@ module AgentHarness
95
278
  body = JSON.parse(http_response.body)
96
279
  output = extract_text_content(body)
97
280
  tokens = extract_tokens(body)
281
+ tool_calls = extract_tool_calls(body)
282
+
283
+ metadata = {transport: :http}
284
+ metadata[:tool_calls] = tool_calls if tool_calls
98
285
 
99
286
  Response.new(
100
287
  output: output,
@@ -103,7 +290,7 @@ module AgentHarness
103
290
  provider: :claude,
104
291
  model: body["model"] || model,
105
292
  tokens: tokens,
106
- metadata: {transport: :http}
293
+ metadata: metadata
107
294
  )
108
295
  rescue JSON::ParserError => e
109
296
  raise ProviderError.new(
@@ -112,6 +299,22 @@ module AgentHarness
112
299
  )
113
300
  end
114
301
 
302
+ def build_streaming_response(accumulated, duration:, model:)
303
+ tool_calls = accumulated[:tool_calls].compact
304
+ metadata = {transport: :http, stream: true}
305
+ metadata[:tool_calls] = tool_calls unless tool_calls.empty?
306
+
307
+ Response.new(
308
+ output: accumulated[:content],
309
+ exit_code: 0,
310
+ duration: duration,
311
+ provider: :claude,
312
+ model: accumulated[:model] || model,
313
+ tokens: accumulated[:usage],
314
+ metadata: metadata
315
+ )
316
+ end
317
+
115
318
  def extract_text_content(body)
116
319
  content = body["content"]
117
320
  return "" unless content.is_a?(Array)
@@ -122,6 +325,23 @@ module AgentHarness
122
325
  .join
123
326
  end
124
327
 
328
+ def extract_tool_calls(body)
329
+ content = body["content"]
330
+ return nil unless content.is_a?(Array)
331
+
332
+ tool_calls = content.filter_map do |block|
333
+ next unless block["type"] == "tool_use"
334
+
335
+ {
336
+ id: block["id"],
337
+ name: block["name"],
338
+ arguments: JSON.generate(block["input"] || {})
339
+ }
340
+ end
341
+
342
+ tool_calls.empty? ? nil : tool_calls
343
+ end
344
+
125
345
  def extract_tokens(body)
126
346
  usage = body["usage"]
127
347
  return nil unless usage
@@ -133,11 +353,15 @@ module AgentHarness
133
353
  end
134
354
 
135
355
  def handle_error_response(http_response, status_code)
356
+ handle_error_response_raw(http_response.body, status_code)
357
+ end
358
+
359
+ def handle_error_response_raw(body_string, status_code)
136
360
  message = begin
137
- body = JSON.parse(http_response.body)
138
- body.dig("error", "message") || body.dig("error", "type") || http_response.body
361
+ body = JSON.parse(body_string)
362
+ body.dig("error", "message") || body.dig("error", "type") || body_string
139
363
  rescue JSON::ParserError
140
- http_response.body
364
+ body_string
141
365
  end
142
366
 
143
367
  case status_code
@@ -164,5 +388,88 @@ module AgentHarness
164
388
  raise ProviderError.new("HTTP #{status_code}: #{message}")
165
389
  end
166
390
  end
391
+
392
+ def build_chat_chunk_callback(on_chunk, on_chat_chunk, observer)
393
+ proc do |chunk|
394
+ on_chunk&.call(chunk)
395
+ on_chat_chunk&.call(chunk)
396
+ observer.on_chat_chunk(chunk) if observer_responds_to?(observer, :on_chat_chunk)
397
+ end
398
+ end
399
+
400
+ def process_content_block_start(payload, accumulated, &on_chunk)
401
+ content_block = payload["content_block"] || {}
402
+
403
+ case content_block["type"]
404
+ when "text"
405
+ emit_text_delta(content_block["text"], accumulated, &on_chunk)
406
+ when "tool_use"
407
+ index = payload["index"] || 0
408
+ accumulated[:tool_calls][index] = {
409
+ id: content_block["id"],
410
+ name: content_block["name"],
411
+ arguments: +"",
412
+ structured_input: content_block["input"],
413
+ saw_delta: false
414
+ }
415
+ on_chunk.call({
416
+ type: :tool_call_start,
417
+ id: content_block["id"],
418
+ name: content_block["name"]
419
+ })
420
+ end
421
+ end
422
+
423
+ def process_content_block_delta(payload, accumulated, &on_chunk)
424
+ delta = payload["delta"] || {}
425
+
426
+ case delta["type"]
427
+ when "text_delta"
428
+ emit_text_delta(delta["text"], accumulated, &on_chunk)
429
+ when "input_json_delta"
430
+ index = payload["index"] || 0
431
+ tool_call = accumulated[:tool_calls][index]
432
+ return unless tool_call
433
+
434
+ partial_json = delta["partial_json"]
435
+ return if partial_json.nil? || partial_json.empty?
436
+
437
+ tool_call[:saw_delta] = true
438
+ tool_call[:arguments] << partial_json
439
+ on_chunk.call({
440
+ type: :tool_call_delta,
441
+ id: tool_call[:id],
442
+ arguments: partial_json
443
+ })
444
+ end
445
+ end
446
+
447
+ def process_content_block_stop(payload, accumulated, &on_chunk)
448
+ index = payload["index"] || 0
449
+ tool_call = accumulated[:tool_calls][index]
450
+ return unless tool_call
451
+
452
+ arguments = finalized_tool_call_arguments(tool_call)
453
+ tool_call[:arguments] = arguments
454
+ tool_call.delete(:structured_input)
455
+ tool_call.delete(:saw_delta)
456
+
457
+ on_chunk.call({
458
+ type: :tool_call_complete,
459
+ id: tool_call[:id],
460
+ name: tool_call[:name],
461
+ arguments: arguments
462
+ })
463
+ end
464
+
465
+ def finalized_tool_call_arguments(tool_call)
466
+ return tool_call[:arguments] if tool_call[:saw_delta]
467
+
468
+ JSON.generate(tool_call[:structured_input] || {})
469
+ end
470
+
471
+ def observer_responds_to?(observer, method_name)
472
+ observer&.respond_to?(method_name)
473
+ end
167
474
  end
168
475
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -270,6 +270,8 @@ require_relative "agent_harness/response"
270
270
  require_relative "agent_harness/token_tracker"
271
271
  require_relative "agent_harness/error_taxonomy"
272
272
  require_relative "agent_harness/text_transport"
273
+ require_relative "agent_harness/openai_compatible_transport"
274
+ require_relative "agent_harness/conversation"
273
275
  require_relative "agent_harness/authentication"
274
276
  require_relative "agent_harness/provider_health_check"
275
277
 
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.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -102,11 +102,13 @@ files:
102
102
  - lib/agent_harness/authentication.rb
103
103
  - lib/agent_harness/command_executor.rb
104
104
  - lib/agent_harness/configuration.rb
105
+ - lib/agent_harness/conversation.rb
105
106
  - lib/agent_harness/docker_command_executor.rb
106
107
  - lib/agent_harness/error_taxonomy.rb
107
108
  - lib/agent_harness/errors.rb
108
109
  - lib/agent_harness/execution_preparation.rb
109
110
  - lib/agent_harness/mcp_server.rb
111
+ - lib/agent_harness/openai_compatible_transport.rb
110
112
  - lib/agent_harness/orchestration/circuit_breaker.rb
111
113
  - lib/agent_harness/orchestration/conductor.rb
112
114
  - lib/agent_harness/orchestration/health_monitor.rb