agent-harness 0.7.2 → 0.7.4
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 +15 -0
- data/lib/agent_harness/errors.rb +7 -0
- data/lib/agent_harness/providers/adapter.rb +27 -0
- data/lib/agent_harness/providers/aider.rb +132 -14
- data/lib/agent_harness/providers/anthropic.rb +163 -23
- data/lib/agent_harness/providers/base.rb +9 -0
- data/lib/agent_harness/providers/github_copilot.rb +435 -494
- data/lib/agent_harness/providers/registry.rb +1 -0
- data/lib/agent_harness/providers/token_usage_parsing.rb +118 -0
- data/lib/agent_harness/text_transport.rb +168 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +2 -0
- metadata +3 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Shared token-usage parsing helpers for providers that extract token
|
|
6
|
+
# counts from JSON/JSONL CLI output.
|
|
7
|
+
#
|
|
8
|
+
# Include this module in any provider that needs to inspect usage
|
|
9
|
+
# payloads (hashes with keys like "input_tokens", "prompt_tokens",
|
|
10
|
+
# "output_tokens", "completion_tokens" and their camelCase variants).
|
|
11
|
+
#
|
|
12
|
+
# Provider-specific behaviour (e.g. where to locate the payload in the
|
|
13
|
+
# CLI output) stays in the provider class; only the reusable parsing
|
|
14
|
+
# and comparison logic lives here.
|
|
15
|
+
module TokenUsageParsing
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
TOKEN_COUNT_KEYS = %w[
|
|
19
|
+
input_tokens
|
|
20
|
+
prompt_tokens
|
|
21
|
+
output_tokens
|
|
22
|
+
completion_tokens
|
|
23
|
+
inputTokens
|
|
24
|
+
promptTokens
|
|
25
|
+
outputTokens
|
|
26
|
+
completionTokens
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
def normalized_model_name(value)
|
|
30
|
+
return nil unless value.is_a?(String)
|
|
31
|
+
|
|
32
|
+
stripped = value.strip
|
|
33
|
+
stripped.empty? ? nil : stripped
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def effective_model_name(runtime = nil)
|
|
37
|
+
normalized_model_name(runtime&.model) || normalized_model_name(@config.model)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def nested_hash_value(value, *keys)
|
|
41
|
+
keys.reduce(value) do |current, key|
|
|
42
|
+
break nil unless current.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
current[key]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalize_token_count(value)
|
|
49
|
+
count = case value
|
|
50
|
+
when Integer
|
|
51
|
+
value
|
|
52
|
+
when String
|
|
53
|
+
Integer(value, exception: false)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
count if count && count >= 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def token_count_for(usage, *keys)
|
|
60
|
+
keys.each do |key|
|
|
61
|
+
value = normalize_token_count(usage[key])
|
|
62
|
+
return value unless value.nil?
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def select_best_usage_payload(candidates)
|
|
68
|
+
candidates
|
|
69
|
+
.select { |usage| usage_with_token_counts?(usage) }
|
|
70
|
+
.max_by { |usage| [usage_token_field_count(usage), usage_token_total(usage)] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def usage_token_field_count(usage)
|
|
74
|
+
return 0 unless usage.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
[
|
|
77
|
+
token_count_for(usage, "input_tokens", "prompt_tokens", "inputTokens", "promptTokens"),
|
|
78
|
+
token_count_for(usage, "output_tokens", "completion_tokens", "outputTokens", "completionTokens")
|
|
79
|
+
].count { |value| !value.nil? }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def usage_token_total(usage)
|
|
83
|
+
return 0 unless usage.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
[
|
|
86
|
+
token_count_for(usage, "input_tokens", "prompt_tokens", "inputTokens", "promptTokens"),
|
|
87
|
+
token_count_for(usage, "output_tokens", "completion_tokens", "outputTokens", "completionTokens")
|
|
88
|
+
].compact.sum
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def usage_with_token_counts?(usage)
|
|
92
|
+
return false unless usage.is_a?(Hash)
|
|
93
|
+
return false unless TOKEN_COUNT_KEYS.any? { |key| usage.key?(key) }
|
|
94
|
+
return false if negative_token_count_present?(usage)
|
|
95
|
+
|
|
96
|
+
token_count_for(usage, "input_tokens", "prompt_tokens", "inputTokens", "promptTokens") ||
|
|
97
|
+
token_count_for(usage, "output_tokens", "completion_tokens", "outputTokens", "completionTokens")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def negative_token_count_present?(usage)
|
|
101
|
+
TOKEN_COUNT_KEYS.any? do |key|
|
|
102
|
+
count = case usage[key]
|
|
103
|
+
when Integer
|
|
104
|
+
usage[key]
|
|
105
|
+
when String
|
|
106
|
+
Integer(usage[key], exception: false)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
count && count < 0
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def token_count_keys
|
|
114
|
+
TOKEN_COUNT_KEYS
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module AgentHarness
|
|
8
|
+
# Direct HTTP transport for text-only provider interactions.
|
|
9
|
+
#
|
|
10
|
+
# Bypasses the CLI entirely by calling the provider's REST API directly.
|
|
11
|
+
# Currently supports Anthropic's Messages API. This transport is used when
|
|
12
|
+
# callers declare a task as text-only via +mode: :text+ on +send_message+.
|
|
13
|
+
#
|
|
14
|
+
# The transport preserves the same Response structure, token tracking,
|
|
15
|
+
# and error classification semantics as the CLI path so that callers
|
|
16
|
+
# do not need to distinguish between transport modes after the call.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# transport = AgentHarness::TextTransport.new(api_key: "sk-ant-...")
|
|
20
|
+
# response = transport.send_message("Summarize this PR", model: "claude-sonnet-4-20250514")
|
|
21
|
+
class TextTransport
|
|
22
|
+
ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
|
|
23
|
+
ANTHROPIC_API_VERSION = "2023-06-01"
|
|
24
|
+
DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
|
25
|
+
DEFAULT_MAX_TOKENS = 4096
|
|
26
|
+
DEFAULT_TIMEOUT = 300
|
|
27
|
+
|
|
28
|
+
# @param api_key [String] Anthropic API key
|
|
29
|
+
# @param logger [Logger, nil] optional logger
|
|
30
|
+
def initialize(api_key:, logger: nil)
|
|
31
|
+
@api_key = api_key
|
|
32
|
+
@logger = logger
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Send a text-only message via the Anthropic Messages API.
|
|
36
|
+
#
|
|
37
|
+
# @param prompt [String] the user prompt
|
|
38
|
+
# @param model [String, nil] model to use (defaults to DEFAULT_MODEL)
|
|
39
|
+
# @param timeout [Integer, nil] request timeout in seconds
|
|
40
|
+
# @param max_tokens [Integer, nil] maximum tokens in the response
|
|
41
|
+
# @return [Response] the response
|
|
42
|
+
# @raise [AuthenticationError] on 401 responses
|
|
43
|
+
# @raise [RateLimitError] on 429 responses
|
|
44
|
+
# @raise [TimeoutError] on network timeouts
|
|
45
|
+
# @raise [ProviderError] on other HTTP errors
|
|
46
|
+
def send_message(prompt, model: nil, timeout: nil, max_tokens: nil)
|
|
47
|
+
model ||= DEFAULT_MODEL
|
|
48
|
+
timeout ||= DEFAULT_TIMEOUT
|
|
49
|
+
max_tokens ||= DEFAULT_MAX_TOKENS
|
|
50
|
+
|
|
51
|
+
uri = URI(ANTHROPIC_API_URL)
|
|
52
|
+
body = {
|
|
53
|
+
model: model,
|
|
54
|
+
max_tokens: max_tokens,
|
|
55
|
+
messages: [{role: "user", content: prompt}]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
start_time = Time.now
|
|
59
|
+
http_response = make_request(uri, body, timeout: timeout)
|
|
60
|
+
duration = Time.now - start_time
|
|
61
|
+
|
|
62
|
+
parse_response(http_response, duration: duration, model: model)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def make_request(uri, body, timeout:)
|
|
68
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
69
|
+
http.use_ssl = true
|
|
70
|
+
http.open_timeout = [timeout, 30].min
|
|
71
|
+
http.read_timeout = timeout
|
|
72
|
+
|
|
73
|
+
request = Net::HTTP::Post.new(uri)
|
|
74
|
+
request["Content-Type"] = "application/json"
|
|
75
|
+
request["x-api-key"] = @api_key
|
|
76
|
+
request["anthropic-version"] = ANTHROPIC_API_VERSION
|
|
77
|
+
request.body = JSON.generate(body)
|
|
78
|
+
|
|
79
|
+
@logger&.debug("[AgentHarness::TextTransport] POST #{uri} model=#{body[:model]}")
|
|
80
|
+
|
|
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)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_response(http_response, duration:, model:)
|
|
89
|
+
status_code = http_response.code.to_i
|
|
90
|
+
|
|
91
|
+
unless status_code == 200
|
|
92
|
+
handle_error_response(http_response, status_code)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
body = JSON.parse(http_response.body)
|
|
96
|
+
output = extract_text_content(body)
|
|
97
|
+
tokens = extract_tokens(body)
|
|
98
|
+
|
|
99
|
+
Response.new(
|
|
100
|
+
output: output,
|
|
101
|
+
exit_code: 0,
|
|
102
|
+
duration: duration,
|
|
103
|
+
provider: :claude,
|
|
104
|
+
model: body["model"] || model,
|
|
105
|
+
tokens: tokens,
|
|
106
|
+
metadata: {transport: :http}
|
|
107
|
+
)
|
|
108
|
+
rescue JSON::ParserError => e
|
|
109
|
+
raise ProviderError.new(
|
|
110
|
+
"Invalid JSON in API response: #{e.message}",
|
|
111
|
+
original_error: e
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def extract_text_content(body)
|
|
116
|
+
content = body["content"]
|
|
117
|
+
return "" unless content.is_a?(Array)
|
|
118
|
+
|
|
119
|
+
content
|
|
120
|
+
.select { |block| block["type"] == "text" }
|
|
121
|
+
.map { |block| block["text"] }
|
|
122
|
+
.join
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def extract_tokens(body)
|
|
126
|
+
usage = body["usage"]
|
|
127
|
+
return nil unless usage
|
|
128
|
+
|
|
129
|
+
input = usage["input_tokens"] || 0
|
|
130
|
+
output = usage["output_tokens"] || 0
|
|
131
|
+
|
|
132
|
+
{input: input, output: output, total: input + output}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def handle_error_response(http_response, status_code)
|
|
136
|
+
message = begin
|
|
137
|
+
body = JSON.parse(http_response.body)
|
|
138
|
+
body.dig("error", "message") || body.dig("error", "type") || http_response.body
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
http_response.body
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
case status_code
|
|
144
|
+
when 401
|
|
145
|
+
raise AuthenticationError.new(
|
|
146
|
+
"API authentication failed: #{message}",
|
|
147
|
+
provider: :claude
|
|
148
|
+
)
|
|
149
|
+
when 403
|
|
150
|
+
raise AuthenticationError.new(
|
|
151
|
+
"API access forbidden: #{message}",
|
|
152
|
+
provider: :claude
|
|
153
|
+
)
|
|
154
|
+
when 429
|
|
155
|
+
raise RateLimitError.new(
|
|
156
|
+
"API rate limit exceeded: #{message}",
|
|
157
|
+
provider: :claude
|
|
158
|
+
)
|
|
159
|
+
when 400
|
|
160
|
+
raise ProviderError.new("Bad request: #{message}")
|
|
161
|
+
when 500, 502, 503, 529
|
|
162
|
+
raise ProviderError.new("Server error (#{status_code}): #{message}")
|
|
163
|
+
else
|
|
164
|
+
raise ProviderError.new("HTTP #{status_code}: #{message}")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/agent_harness.rb
CHANGED
|
@@ -245,6 +245,7 @@ require_relative "agent_harness/docker_command_executor"
|
|
|
245
245
|
require_relative "agent_harness/response"
|
|
246
246
|
require_relative "agent_harness/token_tracker"
|
|
247
247
|
require_relative "agent_harness/error_taxonomy"
|
|
248
|
+
require_relative "agent_harness/text_transport"
|
|
248
249
|
require_relative "agent_harness/authentication"
|
|
249
250
|
require_relative "agent_harness/provider_health_check"
|
|
250
251
|
|
|
@@ -252,6 +253,7 @@ require_relative "agent_harness/provider_health_check"
|
|
|
252
253
|
require_relative "agent_harness/providers/registry"
|
|
253
254
|
require_relative "agent_harness/providers/adapter"
|
|
254
255
|
require_relative "agent_harness/providers/base"
|
|
256
|
+
require_relative "agent_harness/providers/token_usage_parsing"
|
|
255
257
|
require_relative "agent_harness/providers/anthropic"
|
|
256
258
|
require_relative "agent_harness/providers/aider"
|
|
257
259
|
require_relative "agent_harness/providers/codex"
|
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.7.
|
|
4
|
+
version: 0.7.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bart Agapinan
|
|
@@ -127,7 +127,9 @@ files:
|
|
|
127
127
|
- lib/agent_harness/providers/mistral_vibe.rb
|
|
128
128
|
- lib/agent_harness/providers/opencode.rb
|
|
129
129
|
- lib/agent_harness/providers/registry.rb
|
|
130
|
+
- lib/agent_harness/providers/token_usage_parsing.rb
|
|
130
131
|
- lib/agent_harness/response.rb
|
|
132
|
+
- lib/agent_harness/text_transport.rb
|
|
131
133
|
- lib/agent_harness/token_tracker.rb
|
|
132
134
|
- lib/agent_harness/version.rb
|
|
133
135
|
- release-please-config.json
|