agent-harness 0.9.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.
@@ -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.9.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -184,19 +184,43 @@ module AgentHarness
184
184
  Authentication.auth_status(provider_name)
185
185
  end
186
186
 
187
+ # Get authentication flow capabilities for a provider
188
+ # @param provider_name [Symbol] the provider name
189
+ # @return [Hash] capabilities with :auth_type, :auth_url, :refresh keys
190
+ # @raise [ProviderNotFoundError] if provider is unknown
191
+ def auth_capabilities(provider_name)
192
+ Authentication.auth_capabilities(provider_name)
193
+ end
194
+
195
+ # Check whether OAuth URL generation is supported for a provider
196
+ # @param provider_name [Symbol] the provider name
197
+ # @return [Boolean] true if auth_url can be called for the provider
198
+ # @raise [ProviderNotFoundError] if provider is unknown
199
+ def auth_url_supported?(provider_name)
200
+ Authentication.auth_url_supported?(provider_name)
201
+ end
202
+
187
203
  # Generate an OAuth URL for a provider
188
204
  # @param provider_name [Symbol] the provider name
189
205
  # @return [String] the OAuth authorization URL
190
- # @raise [NotImplementedError] if provider doesn't support OAuth
206
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support OAuth
191
207
  def auth_url(provider_name)
192
208
  Authentication.auth_url(provider_name)
193
209
  end
194
210
 
211
+ # Check whether credential refresh is supported for a provider
212
+ # @param provider_name [Symbol] the provider name
213
+ # @return [Boolean] true if refresh_auth can be called for the provider
214
+ # @raise [ProviderNotFoundError] if provider is unknown
215
+ def refresh_auth_supported?(provider_name)
216
+ Authentication.refresh_auth_supported?(provider_name)
217
+ end
218
+
195
219
  # Refresh authentication credentials for a provider
196
220
  # @param provider_name [Symbol] the provider name
197
221
  # @param token [String, nil] OAuth token to store
198
222
  # @return [Hash] result with :success key
199
- # @raise [NotImplementedError] if provider doesn't support credential refresh
223
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support credential refresh
200
224
  def refresh_auth(provider_name, token: nil)
201
225
  Authentication.refresh_auth(provider_name, token: token)
202
226
  end
@@ -246,6 +270,8 @@ require_relative "agent_harness/response"
246
270
  require_relative "agent_harness/token_tracker"
247
271
  require_relative "agent_harness/error_taxonomy"
248
272
  require_relative "agent_harness/text_transport"
273
+ require_relative "agent_harness/openai_compatible_transport"
274
+ require_relative "agent_harness/conversation"
249
275
  require_relative "agent_harness/authentication"
250
276
  require_relative "agent_harness/provider_health_check"
251
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.9.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