agent-harness 0.7.3 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e879475ab73c89cd1dd1a107ce769e355426cee936e95df377ec242312cec4b
4
- data.tar.gz: 492ed111e0b70703f5f55d2a448259450132515a614bc597310ddecea775a313
3
+ metadata.gz: a5bc12df7dec39319082c66c7aa4fc47bfc3f3d1765f572c4faa49677e18bad1
4
+ data.tar.gz: '08138f93d009f4d390917d7f09721adf7e1fbfe0eb2bedb75352b3c2250b245d'
5
5
  SHA512:
6
- metadata.gz: 02c690080d6dc6c39275c5188493c6e6a7a29303af35d1435d249ef996234235fddedb767489d429f0d98283429ec57dc8a033367aea7ca89278596ddf34d452
7
- data.tar.gz: 4a5be3565b1c35b73abc61abc2238b6a5d41416d736162624a0db14fe30e96e01029eb687e35916ecd81cefbbe2d0397113339a80ee3965dcb9b943415223745
6
+ metadata.gz: 0b0870b435dd46293c04eb382476138bb5b2cd1c8e6fc665a9c41fa0b4ed20be09906242a9e492f8c8f4eebd6c2da17c51ddb17860750ed634da220a54c80846
7
+ data.tar.gz: 1cc610554e295e6d9a65c7ac2f44209f99c45eab8b713256397a595d95c8bcd88a8a3463509dc55881b3a22701e142bb70632846b99c2f4d30a05be0888e8fd9
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.7.3"
2
+ ".": "0.8.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.7.4...agent-harness/v0.8.0) (2026-04-19)
4
+
5
+
6
+ ### Features
7
+
8
+ * **providers:** add config_file_content, notify_hook_content, and auth_lock_config ([#131](https://github.com/viamin/agent-harness/issues/131)) ([e95117e](https://github.com/viamin/agent-harness/commit/e95117e8000002972ca0fb31cb90dec035aa88fd))
9
+ * **providers:** add env var name mappings to provider classes ([#122](https://github.com/viamin/agent-harness/issues/122)) ([#133](https://github.com/viamin/agent-harness/issues/133)) ([6be9015](https://github.com/viamin/agent-harness/commit/6be901592afb02337eb2a5269f08e3025c7511c1))
10
+ * **providers:** add error_classification_patterns, noisy_error_patterns, and translate_error to provider classes ([#128](https://github.com/viamin/agent-harness/issues/128)) ([e2dfbed](https://github.com/viamin/agent-harness/commit/e2dfbed064fa26b2cae5691e6586e79900d19d28))
11
+ * **providers:** add parse_rate_limit_reset to provider base class ([#134](https://github.com/viamin/agent-harness/issues/134)) ([c16a6f8](https://github.com/viamin/agent-harness/commit/c16a6f8de312e137e5c3431f32d42b8c65126e0e))
12
+ * **providers:** add test_command_overrides and parse_test_error methods ([#129](https://github.com/viamin/agent-harness/issues/129)) ([a18102d](https://github.com/viamin/agent-harness/commit/a18102d1ee333a1db37f711c65e16dc20d4a0a11)), closes [#125](https://github.com/viamin/agent-harness/issues/125)
13
+ * **providers:** add token_usage_from_api_response to provider classes ([#130](https://github.com/viamin/agent-harness/issues/130)) ([f2c095d](https://github.com/viamin/agent-harness/commit/f2c095dcc0ae0d7822da90f98704597f08e4ed04)), closes [#126](https://github.com/viamin/agent-harness/issues/126)
14
+
15
+ ## [0.7.4](https://github.com/viamin/agent-harness/compare/agent-harness/v0.7.3...agent-harness/v0.7.4) (2026-04-18)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * 119: Claude provider leaks raw --output-format json envelope as response.output ([#120](https://github.com/viamin/agent-harness/issues/120)) ([602a5f9](https://github.com/viamin/agent-harness/commit/602a5f97e009ac59c798c7b1d7342cd43e2e8d4f))
21
+
3
22
  ## [0.7.3](https://github.com/viamin/agent-harness/compare/agent-harness/v0.7.2...agent-harness/v0.7.3) (2026-04-15)
4
23
 
5
24
 
@@ -781,6 +781,50 @@ module AgentHarness
781
781
  {}
782
782
  end
783
783
 
784
+ # Error classification patterns for downstream consumers
785
+ #
786
+ # Returns patterns grouped by classification category. These patterns
787
+ # encode provider-specific knowledge about how each CLI reports errors
788
+ # and are intended for use by callers outside agent-harness.
789
+ #
790
+ # @return [Hash<Symbol, Array<Regexp>>] patterns by category
791
+ # (:auth_expired, :abort, :authentication, :quota)
792
+ def error_classification_patterns
793
+ {
794
+ auth_expired: [],
795
+ abort: [],
796
+ authentication: [],
797
+ quota: [
798
+ /requires more credits/i,
799
+ /insufficient credits/i,
800
+ /credit.*exceeded/i,
801
+ /spend limit.*reached/i,
802
+ /billing.*limit/i
803
+ ]
804
+ }
805
+ end
806
+
807
+ # Patterns matching noisy/non-actionable error output
808
+ #
809
+ # Downstream consumers can use these to filter out log noise
810
+ # from provider stderr/stdout that is not meaningful for users.
811
+ #
812
+ # @return [Array<Regexp>] noisy error patterns
813
+ def noisy_error_patterns
814
+ []
815
+ end
816
+
817
+ # Translate a raw error message into a user-friendly string
818
+ #
819
+ # Providers override this to map CLI-specific error output
820
+ # into concise, actionable messages.
821
+ #
822
+ # @param message [String] raw error message
823
+ # @return [String] translated message (or the original if no match)
824
+ def translate_error(message)
825
+ message
826
+ end
827
+
784
828
  # Authentication type for this provider
785
829
  #
786
830
  # @return [Symbol] :oauth for token-based auth that can expire,
@@ -902,6 +946,17 @@ module AgentHarness
902
946
  []
903
947
  end
904
948
 
949
+ # Extract token usage from an API response body
950
+ #
951
+ # Parses the provider-specific API response shape and returns
952
+ # normalized token counts.
953
+ #
954
+ # @param body [Hash] the parsed JSON response body from the provider API
955
+ # @return [Hash] with :input_tokens and :output_tokens keys, or empty hash
956
+ def token_usage_from_api_response(body)
957
+ {}
958
+ end
959
+
905
960
  # Whether this provider can extract token usage from CLI output
906
961
  #
907
962
  # @return [Boolean] true if the provider returns token counts
@@ -1020,6 +1075,35 @@ module AgentHarness
1020
1075
  nil
1021
1076
  end
1022
1077
 
1078
+ # Generate provider-specific config file content.
1079
+ #
1080
+ # Providers that require a config file written before CLI execution
1081
+ # (e.g. Codex TOML, Kilocode JSON) should override this method.
1082
+ #
1083
+ # @param options [Hash] provider-specific options for config generation
1084
+ # @return [String, nil] config file content, or nil when no config is needed
1085
+ def config_file_content(options = {})
1086
+ nil
1087
+ end
1088
+
1089
+ # Generate provider-specific notification hook content.
1090
+ #
1091
+ # Providers that support notification hooks appended to their config
1092
+ # file should override this method.
1093
+ #
1094
+ # @return [String, nil] notify hook content, or nil when not applicable
1095
+ def notify_hook_content
1096
+ nil
1097
+ end
1098
+
1099
+ # Auth lock configuration for providers that need file-based lock
1100
+ # serialization (e.g. OAuth refresh token coordination).
1101
+ #
1102
+ # @return [Hash, nil] lock config with :path and :timeout keys, or nil
1103
+ def auth_lock_config
1104
+ nil
1105
+ end
1106
+
1023
1107
  private
1024
1108
 
1025
1109
  def classify_smoke_test_message(message)
@@ -170,6 +170,10 @@ module AgentHarness
170
170
  }
171
171
  end
172
172
 
173
+ def api_key_env_var_names = ["ANTHROPIC_API_KEY"]
174
+
175
+ def api_key_unset_vars = ["ANTHROPIC_BASE_URL", "ANTHROPIC_HEADER_X_AGENT_RUN_ID", "ANTHROPIC_HEADER_X_PROXY_TOKEN"]
176
+
173
177
  def error_patterns
174
178
  COMMON_ERROR_PATTERNS.merge(
175
179
  auth_expired: COMMON_ERROR_PATTERNS[:auth_expired] + [/incorrect.*api.*key/i],
@@ -14,6 +14,8 @@ module AgentHarness
14
14
  # provider = AgentHarness::Providers::Anthropic.new
15
15
  # response = provider.send_message(prompt: "Hello!")
16
16
  class Anthropic < Base
17
+ include RateLimitResetParsing
18
+
17
19
  # Model name pattern for Anthropic Claude models
18
20
  MODEL_PATTERN = /^claude-[\d.-]+-(?:opus|sonnet|haiku)(?:-\d{8})?$/i
19
21
  SUPPORTED_CLI_VERSION = "2.1.92"
@@ -161,8 +163,81 @@ module AgentHarness
161
163
  Base::DEFAULT_SMOKE_TEST_CONTRACT
162
164
  end
163
165
 
166
+ # Parse a raw Claude CLI --output-format=json envelope into its components.
167
+ #
168
+ # Downstream callers that capture Claude CLI stdout directly (e.g. container
169
+ # execution plans) can use this to extract the assistant text, error state,
170
+ # token usage, and structured metadata without re-implementing the parsing.
171
+ #
172
+ # @param json_string [String] raw JSON envelope from Claude CLI stdout
173
+ # @return [Hash, nil] parsed components or nil if not a valid envelope
174
+ # - :output [String] the assistant's final text (the "result" field)
175
+ # - :error [String, nil] error message if is_error was true
176
+ # - :tokens [Hash, nil] {input:, output:, total:} token counts
177
+ # - :metadata [Hash] structured metadata (cost_usd, session_id, etc.)
178
+ def parse_cli_json_envelope(json_string)
179
+ return nil if json_string.nil? || json_string.empty?
180
+
181
+ parsed = JSON.parse(json_string)
182
+ return nil unless parsed.is_a?(Hash) && parsed.key?("result")
183
+
184
+ output = parsed["result"]
185
+ error = nil
186
+
187
+ if parsed["is_error"]
188
+ error = classify_error_message(output || "Unknown Claude CLI error")
189
+ end
190
+
191
+ tokens = extract_tokens(parsed)
192
+ metadata = extract_envelope_metadata(parsed)
193
+
194
+ {output: output, error: error, tokens: tokens, metadata: metadata}
195
+ rescue JSON::ParserError
196
+ nil
197
+ end
198
+
164
199
  private
165
200
 
201
+ def classify_error_message(message)
202
+ msg_lower = message.downcase
203
+
204
+ if msg_lower.include?("rate limit") || msg_lower.include?("session limit")
205
+ "Rate limit exceeded"
206
+ elsif msg_lower.include?("deprecat") || msg_lower.include?("end-of-life")
207
+ "Model deprecated"
208
+ elsif msg_lower.include?("oauth token") || msg_lower.include?("authentication")
209
+ "Authentication error"
210
+ else
211
+ message
212
+ end
213
+ end
214
+
215
+ def extract_tokens(parsed)
216
+ usage = parsed["usage"]
217
+ return nil unless usage
218
+
219
+ input = usage["input_tokens"]
220
+ output = usage["output_tokens"]
221
+ return nil unless input || output
222
+
223
+ input ||= 0
224
+ output ||= 0
225
+
226
+ {input: input, output: output, total: input + output}
227
+ end
228
+
229
+ def extract_envelope_metadata(parsed)
230
+ meta = {}
231
+ meta[:cost_usd] = parsed["total_cost_usd"] if parsed.key?("total_cost_usd")
232
+ meta[:session_id] = parsed["session_id"] if parsed.key?("session_id")
233
+ meta[:stop_reason] = parsed["stop_reason"] if parsed.key?("stop_reason")
234
+ meta[:terminal_reason] = parsed["terminal_reason"] if parsed.key?("terminal_reason")
235
+ meta[:num_turns] = parsed["num_turns"] if parsed.key?("num_turns")
236
+ meta[:duration_ms] = parsed["duration_ms"] if parsed.key?("duration_ms")
237
+ meta[:duration_api_ms] = parsed["duration_api_ms"] if parsed.key?("duration_api_ms")
238
+ meta
239
+ end
240
+
166
241
  def validate_version!(version)
167
242
  unless version.is_a?(String) && !version.strip.empty?
168
243
  raise ArgumentError, "Invalid version: #{version.inspect}. " \
@@ -306,6 +381,12 @@ module AgentHarness
306
381
  cleanup_mcp_tempfiles!
307
382
  end
308
383
 
384
+ def api_key_env_var_names = ["ANTHROPIC_API_KEY"]
385
+
386
+ def api_key_unset_vars = ["ANTHROPIC_BASE_URL", "ANTHROPIC_HEADER_X_AGENT_RUN_ID", "ANTHROPIC_HEADER_X_PROXY_TOKEN"]
387
+
388
+ def subscription_unset_vars = ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"] + api_key_unset_vars
389
+
309
390
  def supports_mcp?
310
391
  true
311
392
  end
@@ -341,6 +422,16 @@ module AgentHarness
341
422
  true
342
423
  end
343
424
 
425
+ def token_usage_from_api_response(body)
426
+ usage = body&.dig("usage")
427
+ return {} unless usage
428
+
429
+ {
430
+ input_tokens: usage["input_tokens"].to_i,
431
+ output_tokens: usage["output_tokens"].to_i
432
+ }
433
+ end
434
+
344
435
  def execution_semantics
345
436
  {
346
437
  prompt_delivery: :arg,
@@ -401,6 +492,15 @@ module AgentHarness
401
492
  }
402
493
  end
403
494
 
495
+ def error_classification_patterns
496
+ super.merge(
497
+ abort: [
498
+ /free tier limit reached/i,
499
+ /please upgrade to a paid plan/i
500
+ ]
501
+ )
502
+ end
503
+
404
504
  def fetch_mcp_servers
405
505
  return [] unless self.class.available?
406
506
 
@@ -473,17 +573,24 @@ module AgentHarness
473
573
  output = result.stdout
474
574
  error = nil
475
575
  tokens = nil
576
+ metadata = {}
476
577
 
477
578
  if result.failed?
478
579
  combined = [result.stdout, result.stderr].compact.join("\n")
479
580
  error = classify_error_message(combined)
480
581
  end
481
582
 
482
- # Parse JSON output to extract result text and token usage
583
+ # Parse JSON output to extract result text, token usage, and metadata
483
584
  parsed = parse_json_output(output)
484
585
  if parsed
586
+ # Handle is_error envelopes as provider errors
587
+ if parsed["is_error"]
588
+ error ||= classify_error_message(parsed["result"] || "Unknown Claude CLI error")
589
+ end
590
+
485
591
  output = parsed["result"] || output
486
592
  tokens = extract_tokens(parsed)
593
+ metadata = extract_envelope_metadata(parsed)
487
594
  end
488
595
 
489
596
  Response.new(
@@ -493,6 +600,7 @@ module AgentHarness
493
600
  provider: self.class.provider_name,
494
601
  model: @config.model,
495
602
  tokens: tokens,
603
+ metadata: metadata,
496
604
  error: error
497
605
  )
498
606
  end
@@ -572,32 +680,18 @@ module AgentHarness
572
680
  nil
573
681
  end
574
682
 
575
- def extract_tokens(parsed)
576
- usage = parsed["usage"]
577
- return nil unless usage
578
-
579
- input = usage["input_tokens"]
580
- output = usage["output_tokens"]
581
- return nil unless input || output
582
-
583
- input ||= 0
584
- output ||= 0
683
+ # Delegate to class-level implementations so both instance and class
684
+ # methods share a single definition.
685
+ def extract_envelope_metadata(parsed)
686
+ self.class.send(:extract_envelope_metadata, parsed)
687
+ end
585
688
 
586
- {input: input, output: output, total: input + output}
689
+ def extract_tokens(parsed)
690
+ self.class.send(:extract_tokens, parsed)
587
691
  end
588
692
 
589
693
  def classify_error_message(message)
590
- msg_lower = message.downcase
591
-
592
- if msg_lower.include?("rate limit") || msg_lower.include?("session limit")
593
- "Rate limit exceeded"
594
- elsif msg_lower.include?("deprecat") || msg_lower.include?("end-of-life")
595
- "Model deprecated"
596
- elsif msg_lower.include?("oauth token") || msg_lower.include?("authentication")
597
- "Authentication error"
598
- else
599
- message
600
- end
694
+ self.class.send(:classify_error_message, message)
601
695
  end
602
696
 
603
697
  def parse_claude_mcp_output(output)
@@ -205,6 +205,62 @@ module AgentHarness
205
205
  @executor.is_a?(DockerCommandExecutor)
206
206
  end
207
207
 
208
+ # Environment variable names that the provider's CLI reads for API key authentication.
209
+ #
210
+ # @return [Array<String>] env var names (empty by default)
211
+ def api_key_env_var_names = []
212
+
213
+ # Environment variable names to unset when the caller supplies its own API key,
214
+ # preventing the CLI from reading stale or conflicting proxy/header variables.
215
+ #
216
+ # @return [Array<String>] env var names (empty by default)
217
+ def api_key_unset_vars = []
218
+
219
+ # Environment variable names to unset when the caller uses subscription-based auth,
220
+ # ensuring the CLI does not pick up API-key or proxy variables that would conflict.
221
+ #
222
+ # @return [Array<String>] env var names (empty by default)
223
+ def subscription_unset_vars = []
224
+
225
+ # Provider-specific environment variable overrides that the caller should set
226
+ # when invoking the CLI (e.g. feature flags or sandbox controls).
227
+ #
228
+ # @return [Hash{String => String}] env var name => value (empty by default)
229
+ def cli_env_overrides = {}
230
+
231
+ # Additional CLI flags for health-check/test invocations
232
+ #
233
+ # Providers override this to supply flags that should be appended
234
+ # when the CLI is invoked in a test or smoke-test context.
235
+ #
236
+ # @return [Array<String>] extra CLI flags (empty by default)
237
+ def test_command_overrides
238
+ []
239
+ end
240
+
241
+ # Parse provider-specific error information from test output
242
+ #
243
+ # Providers override this to extract structured error details from
244
+ # CLI output or sidecar files produced during a test invocation.
245
+ #
246
+ # @param output [String] the CLI stdout/stderr output
247
+ # @param files [Hash] mapping of logical names to file paths
248
+ # @return [Hash, nil] structured error hash or nil if no error detected
249
+ def parse_test_error(output:, files: {})
250
+ nil
251
+ end
252
+
253
+ # Parse rate-limit reset time from provider error output.
254
+ #
255
+ # Providers that emit rate-limit reset times should override this
256
+ # method (or include RateLimitResetParsing for the common format).
257
+ #
258
+ # @param text [String, nil] error output text
259
+ # @return [Time, nil] UTC reset time, or nil if not parseable
260
+ def parse_rate_limit_reset(text)
261
+ nil
262
+ end
263
+
208
264
  protected
209
265
 
210
266
  # Build CLI command - override in subclasses
@@ -8,6 +8,8 @@ module AgentHarness
8
8
  #
9
9
  # Provides integration with the OpenAI Codex CLI tool.
10
10
  class Codex < Base
11
+ include RateLimitResetParsing
12
+
11
13
  SUPPORTED_CLI_VERSION = "0.116.0"
12
14
  SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 0.117.0").freeze
13
15
  OAUTH_REFRESH_FAILURE_PATTERNS = [
@@ -167,10 +169,32 @@ module AgentHarness
167
169
  }
168
170
  end
169
171
 
172
+ def api_key_env_var_names = ["OPENAI_API_KEY"]
173
+
174
+ def api_key_unset_vars = ["OPENAI_BASE_URL", "OPENAI_HEADER_X_AGENT_RUN_ID", "OPENAI_HEADER_X_PROXY_TOKEN"]
175
+
176
+ def subscription_unset_vars = ["OPENAI_API_KEY", "OPENAI_BASE_URL"] + api_key_unset_vars
177
+
178
+ def cli_env_overrides = {"PAID_CODEX_SUBSCRIPTION_AUTH" => "1"}
179
+
180
+ def test_command_overrides
181
+ ["--skip-git-repo-check", "--output-last-message"]
182
+ end
183
+
170
184
  def dangerous_mode_flags
171
185
  ["--full-auto"]
172
186
  end
173
187
 
188
+ def token_usage_from_api_response(body)
189
+ usage = body&.dig("usage")
190
+ return {} unless usage
191
+
192
+ {
193
+ input_tokens: usage["prompt_tokens"].to_i,
194
+ output_tokens: usage["completion_tokens"].to_i
195
+ }
196
+ end
197
+
174
198
  def execution_semantics
175
199
  {
176
200
  prompt_delivery: :arg,
@@ -216,6 +240,31 @@ module AgentHarness
216
240
  }
217
241
  end
218
242
 
243
+ def error_classification_patterns
244
+ super.merge(
245
+ auth_expired: [
246
+ /refresh_token_reused/i,
247
+ /refresh token has already been used/i,
248
+ /Please log out and sign in again/i,
249
+ /authentication_error/i,
250
+ /invalid_grant/i,
251
+ /Token is expired or invalid/i
252
+ ],
253
+ abort: [
254
+ /free tier limit reached/i,
255
+ /please upgrade to a paid plan/i
256
+ ]
257
+ )
258
+ end
259
+
260
+ def translate_error(message)
261
+ case message
262
+ when /refresh_token_reused/i then "Codex authentication expired. Please re-authenticate."
263
+ when /free tier limit/i then "Codex free tier limit reached."
264
+ else message
265
+ end
266
+ end
267
+
219
268
  def auth_status
220
269
  api_key = ENV["OPENAI_API_KEY"]
221
270
  if api_key && !api_key.strip.empty?
@@ -272,6 +321,28 @@ module AgentHarness
272
321
  {valid: errors.empty?, errors: errors}
273
322
  end
274
323
 
324
+ def config_file_content(options = {})
325
+ <<~TOML
326
+ [chatgpt]
327
+ model_provider = "#{escape_toml_string(options[:model_provider])}"
328
+ base_url = "#{escape_toml_string(options[:base_url])}"
329
+ env_key = "#{escape_toml_string(options[:env_key])}"
330
+ wire_api = "#{escape_toml_string(options[:wire_api])}"
331
+ TOML
332
+ end
333
+
334
+ def notify_hook_content
335
+ <<~TOML
336
+
337
+ [notify]
338
+ # Paid notification hook
339
+ TOML
340
+ end
341
+
342
+ def auth_lock_config
343
+ {path: "/tmp/codex-auth.lock", timeout: 30}
344
+ end
345
+
275
346
  protected
276
347
 
277
348
  def parse_response(result, duration:)
@@ -387,6 +458,10 @@ module AgentHarness
387
458
 
388
459
  private
389
460
 
461
+ def escape_toml_string(val)
462
+ val.to_s.gsub("\\") { "\\\\" }.gsub('"') { "\\\"" }.gsub("\n") { "\\n" }
463
+ end
464
+
390
465
  def parse_jsonl_output(raw_output)
391
466
  return nil if raw_output.nil? || raw_output.strip.empty?
392
467
 
@@ -12,6 +12,8 @@ module AgentHarness
12
12
  # provider = AgentHarness::Providers::Cursor.new
13
13
  # response = provider.send_message(prompt: "Hello!")
14
14
  class Cursor < Base
15
+ include RateLimitResetParsing
16
+
15
17
  INSTALL_SCRIPT_URL = "https://cursor.com/install"
16
18
  INSTALL_TARGET_LATEST = "latest"
17
19
  INSTALL_BUILD = "2026.03.30-a5d3e17"
@@ -206,6 +208,10 @@ module AgentHarness
206
208
  fetch_mcp_servers_cli || fetch_mcp_servers_config
207
209
  end
208
210
 
211
+ def api_key_env_var_names = ["ANTHROPIC_API_KEY"]
212
+
213
+ def api_key_unset_vars = ["ANTHROPIC_BASE_URL", "ANTHROPIC_HEADER_X_AGENT_RUN_ID", "ANTHROPIC_HEADER_X_PROXY_TOKEN"]
214
+
209
215
  def auth_type
210
216
  :oauth
211
217
  end
@@ -163,10 +163,47 @@ module AgentHarness
163
163
  }
164
164
  end
165
165
 
166
+ def parse_test_error(output:, files: {})
167
+ error_file = files.values.find { |path| path.match?(/gemini-client-error-.*\.json/) }
168
+ return nil unless error_file
169
+
170
+ error_data = begin
171
+ JSON.parse(File.read(error_file))
172
+ rescue JSON::ParserError, Errno::ENOENT
173
+ nil
174
+ end
175
+ return nil unless error_data
176
+
177
+ {message: error_data.dig("error", "message") || output, type: :configuration}
178
+ end
179
+
180
+ def api_key_env_var_names = ["GEMINI_API_KEY", "GOOGLE_API_KEY"]
181
+
182
+ def api_key_unset_vars = ["GOOGLE_GEMINI_BASE_URL", "GOOGLE_GENAI_BASE_URL", "GEMINI_CLI_CUSTOM_HEADERS"]
183
+
184
+ def subscription_unset_vars = ["GEMINI_API_KEY", "GOOGLE_GEMINI_BASE_URL", "GOOGLE_GENAI_BASE_URL", "GEMINI_CLI_CUSTOM_HEADERS"]
185
+
186
+ def cli_env_overrides
187
+ {
188
+ "GEMINI_SANDBOX" => "false",
189
+ "GEMINI_CLI_DISABLE_RETRIES" => "true"
190
+ }
191
+ end
192
+
166
193
  def auth_type
167
194
  :oauth
168
195
  end
169
196
 
197
+ def token_usage_from_api_response(body)
198
+ usage = body&.dig("usageMetadata")
199
+ return {} unless usage
200
+
201
+ {
202
+ input_tokens: usage["promptTokenCount"].to_i,
203
+ output_tokens: usage["candidatesTokenCount"].to_i
204
+ }
205
+ end
206
+
170
207
  def execution_semantics
171
208
  {
172
209
  prompt_delivery: :flag,
@@ -204,6 +241,35 @@ module AgentHarness
204
241
  }
205
242
  end
206
243
 
244
+ def error_classification_patterns
245
+ super.merge(
246
+ authentication: [
247
+ /GEMINI_API_KEY/i,
248
+ /GOOGLE_GENAI_USE_VERTEXAI/i,
249
+ /GOOGLE_GENAI_USE_GCA/i,
250
+ /ValidationRequiredError/i,
251
+ /API key not configured for google/i,
252
+ /API key not valid/i
253
+ ]
254
+ )
255
+ end
256
+
257
+ def noisy_error_patterns
258
+ [
259
+ /Error when talking to Gemini API/i,
260
+ /service=.*status/i,
261
+ /loading\.\.\./i,
262
+ /subscribing/i
263
+ ]
264
+ end
265
+
266
+ def translate_error(message)
267
+ case message
268
+ when /API key not configured/i then "Gemini API key not set. Run: export GEMINI_API_KEY=..."
269
+ else message
270
+ end
271
+ end
272
+
207
273
  def auth_status
208
274
  api_key = [ENV["GEMINI_API_KEY"], ENV["GOOGLE_API_KEY"]].find { |key| key && !key.strip.empty? }
209
275
  if api_key
@@ -191,6 +191,13 @@ module AgentHarness
191
191
  }
192
192
  end
193
193
 
194
+ def translate_error(message)
195
+ case message
196
+ when /github-copilot-cli.*not found/i then "GitHub Copilot CLI not installed."
197
+ else message
198
+ end
199
+ end
200
+
194
201
  def supports_token_counting?
195
202
  supports_json_output_format?
196
203
  end
@@ -113,6 +113,10 @@ module AgentHarness
113
113
  "Kilocode CLI"
114
114
  end
115
115
 
116
+ def test_command_overrides
117
+ ["--auto", "--print-logs"]
118
+ end
119
+
116
120
  def capabilities
117
121
  {
118
122
  streaming: false,
@@ -125,6 +129,13 @@ module AgentHarness
125
129
  }
126
130
  end
127
131
 
132
+ def config_file_content(options = {})
133
+ {
134
+ provider: options[:api_provider],
135
+ model: options[:model_id]
136
+ }.to_json
137
+ end
138
+
128
139
  def error_patterns
129
140
  COMMON_ERROR_PATTERNS
130
141
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Providers
5
+ # Shared rate-limit reset time parsing for providers whose CLIs emit
6
+ # standard reset-time formats in error output.
7
+ #
8
+ # Include this module in any provider that uses the common format:
9
+ # - "retry after 60s" (seconds)
10
+ # - "reset at 1234567890" (unix timestamp)
11
+ # - "resets 5am (UTC)" (time today/tomorrow)
12
+ # - "resets 5:00am (UTC)" (time with minutes)
13
+ # - "resets Jan 15, 5pm (UTC)" (date + time)
14
+ #
15
+ # Providers with a different format should override
16
+ # +parse_rate_limit_reset+ directly instead of including this module.
17
+ module RateLimitResetParsing
18
+ # Parse rate-limit reset time from provider error output.
19
+ #
20
+ # @param text [String, nil] error output text
21
+ # @return [Time, nil] UTC reset time, or nil if not parseable
22
+ def parse_rate_limit_reset(text)
23
+ return nil unless text
24
+
25
+ parse_retry_after(text) ||
26
+ parse_reset_at(text) ||
27
+ parse_resets_time(text) ||
28
+ parse_resets_date_time(text)
29
+ end
30
+
31
+ private
32
+
33
+ # "retry after 60s"
34
+ def parse_retry_after(text)
35
+ match = text.match(/retry\s+after\s+(\d+)\s*s/i)
36
+ return unless match
37
+
38
+ Time.now.utc + match[1].to_i
39
+ end
40
+
41
+ # "reset at 1234567890" (unix timestamp)
42
+ def parse_reset_at(text)
43
+ match = text.match(/reset\s+at\s+(\d{10})/i)
44
+ return unless match
45
+
46
+ Time.at(match[1].to_i).utc
47
+ end
48
+
49
+ # "resets 5am (UTC)" / "resets 5:00am (UTC)"
50
+ def parse_resets_time(text)
51
+ match = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(UTC\)/i)
52
+ return unless match
53
+
54
+ hour = match[1].to_i
55
+ minute = match[2]&.to_i || 0
56
+ meridiem = match[3].downcase
57
+ hour = to_24h(hour, meridiem)
58
+
59
+ now = Time.now.utc
60
+ reset_time = Time.utc(now.year, now.month, now.day, hour, minute)
61
+ reset_time += 86_400 if reset_time <= now
62
+ reset_time
63
+ end
64
+
65
+ # "resets Jan 15, 5pm (UTC)"
66
+ def parse_resets_date_time(text)
67
+ match = text.match(/resets\s+(\w{3})\s+(\d{1,2}),?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(UTC\)/i)
68
+ return unless match
69
+
70
+ month = Date::ABBR_MONTHNAMES.index(match[1].capitalize)
71
+ return unless month
72
+
73
+ day = match[2].to_i
74
+ hour = match[3].to_i
75
+ minute = match[4]&.to_i || 0
76
+ meridiem = match[5].downcase
77
+ hour = to_24h(hour, meridiem)
78
+
79
+ year = Time.now.utc.year
80
+ year += 1 if month < Time.now.utc.month
81
+
82
+ Time.utc(year, month, day, hour, minute)
83
+ end
84
+
85
+ def to_24h(hour, meridiem)
86
+ hour += 12 if meridiem == "pm" && hour != 12
87
+ hour = 0 if meridiem == "am" && hour == 12
88
+ hour
89
+ end
90
+ end
91
+ end
92
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.7.3"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -254,6 +254,7 @@ require_relative "agent_harness/providers/registry"
254
254
  require_relative "agent_harness/providers/adapter"
255
255
  require_relative "agent_harness/providers/base"
256
256
  require_relative "agent_harness/providers/token_usage_parsing"
257
+ require_relative "agent_harness/providers/rate_limit_reset_parsing"
257
258
  require_relative "agent_harness/providers/anthropic"
258
259
  require_relative "agent_harness/providers/aider"
259
260
  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.3
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -126,6 +126,7 @@ files:
126
126
  - lib/agent_harness/providers/kilocode.rb
127
127
  - lib/agent_harness/providers/mistral_vibe.rb
128
128
  - lib/agent_harness/providers/opencode.rb
129
+ - lib/agent_harness/providers/rate_limit_reset_parsing.rb
129
130
  - lib/agent_harness/providers/registry.rb
130
131
  - lib/agent_harness/providers/token_usage_parsing.rb
131
132
  - lib/agent_harness/response.rb