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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +11 -0
- data/lib/agent_harness/conversation.rb +326 -0
- data/lib/agent_harness/mcp_server.rb +32 -0
- data/lib/agent_harness/openai_compatible_transport.rb +391 -0
- data/lib/agent_harness/provider_runtime.rb +40 -4
- data/lib/agent_harness/providers/adapter.rb +62 -3
- data/lib/agent_harness/providers/anthropic.rb +30 -0
- data/lib/agent_harness/providers/base.rb +142 -0
- data/lib/agent_harness/providers/github_copilot.rb +61 -0
- data/lib/agent_harness/text_transport.rb +320 -13
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +2 -0
- metadata +3 -1
|
@@ -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(
|
|
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 =
|
|
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
|
-
|
|
183
|
+
def parse_sse_stream(http_response, accumulated, &on_chunk)
|
|
184
|
+
buffer = +""
|
|
185
|
+
event_name = nil
|
|
186
|
+
data_lines = []
|
|
80
187
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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:
|
|
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(
|
|
138
|
-
body.dig("error", "message") || body.dig("error", "type") ||
|
|
361
|
+
body = JSON.parse(body_string)
|
|
362
|
+
body.dig("error", "message") || body.dig("error", "type") || body_string
|
|
139
363
|
rescue JSON::ParserError
|
|
140
|
-
|
|
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
|
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.
|
|
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
|