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.
@@ -486,6 +486,7 @@ module AgentHarness
486
486
  uses_subcommand: nil,
487
487
  supports_mcp: false,
488
488
  supported_mcp_transports: [],
489
+ supports_token_counting: false,
489
490
  supports_sessions: false,
490
491
  supports_dangerous_mode: false
491
492
  },
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.7.2"
4
+ VERSION = "0.7.4"
5
5
  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.2
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