agent-harness 0.5.9 → 0.6.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: f476f6224ed25cc79c7bc9c8fb239c1df9f787cd59ff279b8c509b7d515d2727
4
- data.tar.gz: c0faee9c6d5c139db019707b9174d2d25a4a2fc4a85684ce55eb7baede9fd3be
3
+ metadata.gz: 11c53accd50a5842f5f67a3ef1adb97e2d25539966e08cf5ed1a2659b047ce0b
4
+ data.tar.gz: 562f0baad4bdc24dda2dcb7d65ef93d7657be1e64e2460400afc28e7ac419afa
5
5
  SHA512:
6
- metadata.gz: 113c0d8afbf5e77c6abcb1f78f0793646b72137e48a9776bfd951ee98c5412d3606628ea19ce4b105a6d8c05d1143a12a5607e1bad82462c629f6bdf3907985b
7
- data.tar.gz: 4cc2f012c75314a2d096147e4bae9afbed565181878a907339410f7cfdb3ea0cff03e96704a6d3d93cd4f3825eb9f21a97d5351b852d72ec4e09b21a3359df68
6
+ metadata.gz: c9e7fb58eb6298e79f193b9de47c5fff995f92be7a9aff396277cf64489022d92030464b5cc08da506cc4496a4872dfc1fea1ca880f3d4cb82ad5133eaa65622
7
+ data.tar.gz: 40ca98102aedafdefb12d00794b721b0eb04f376eb7695034ca840ce17582abfb0741cd689f5c1a27b516acffa85399f9b58550fb516fe8e9e5652c3bb01d8d0
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.5.9"
2
+ ".": "0.6.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.9...agent-harness/v0.6.0) (2026-04-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * **aider:** extract token usage via --llm-history-file ([0fff343](https://github.com/viamin/agent-harness/commit/0fff343f943d93899d0222b16ffa9832611289ff)), closes [#100](https://github.com/viamin/agent-harness/issues/100)
9
+
3
10
  ## [0.5.9](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.8...agent-harness/v0.5.9) (2026-04-12)
4
11
 
5
12
 
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+ require "shellwords"
5
+ require "tmpdir"
6
+
3
7
  module AgentHarness
4
8
  module Providers
5
9
  # Aider AI coding assistant provider
@@ -192,29 +196,382 @@ module AgentHarness
192
196
  ["--restore-chat-history", session_id]
193
197
  end
194
198
 
199
+ def send_message(prompt:, **options)
200
+ log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
201
+
202
+ options = normalize_provider_runtime(options)
203
+ runtime = options[:provider_runtime]
204
+
205
+ options = normalize_mcp_servers(options)
206
+ validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
207
+
208
+ llm_history_path = generate_llm_history_path
209
+ command = build_command(prompt, options.merge(llm_history_path: llm_history_path))
210
+ preparation = build_execution_preparation(options)
211
+ timeout = options[:timeout] || @config.timeout || default_timeout
212
+
213
+ start_time = Time.now
214
+ result = execute_with_timeout(
215
+ command,
216
+ timeout: timeout,
217
+ env: build_env(options),
218
+ preparation: preparation,
219
+ **command_execution_options(options)
220
+ )
221
+ duration = Time.now - start_time
222
+
223
+ response = parse_response(result, duration: duration, llm_history_path: llm_history_path)
224
+ if runtime&.model
225
+ response = Response.new(
226
+ output: response.output,
227
+ exit_code: response.exit_code,
228
+ duration: response.duration,
229
+ provider: response.provider,
230
+ model: runtime.model,
231
+ tokens: response.tokens,
232
+ metadata: response.metadata,
233
+ error: response.error
234
+ )
235
+ end
236
+
237
+ track_tokens(response) if response.tokens
238
+
239
+ log_debug("send_message_complete", duration: duration, tokens: response.tokens)
240
+
241
+ response
242
+ rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
243
+ raise
244
+ rescue => e
245
+ handle_error(e, prompt: prompt, options: options)
246
+ ensure
247
+ cleanup_llm_history_file!(llm_history_path)
248
+ end
249
+
195
250
  protected
196
251
 
197
252
  def build_command(prompt, options)
198
253
  cmd = [self.class.binary_name]
254
+ runtime = options[:provider_runtime]
199
255
 
200
- # Run in non-interactive mode
201
256
  cmd << "--yes"
202
257
 
203
- if @config.model && !@config.model.empty?
204
- cmd += ["--model", @config.model]
258
+ if options[:llm_history_path]
259
+ cmd += ["--llm-history-file", options[:llm_history_path]]
260
+ end
261
+
262
+ model = runtime&.model || @config.model
263
+ if model && !model.empty?
264
+ cmd += ["--model", model]
205
265
  end
206
266
 
207
267
  if options[:session]
208
268
  cmd += session_flags(options[:session])
209
269
  end
210
270
 
271
+ if runtime&.flags&.any?
272
+ validate_runtime_flags!(runtime.flags)
273
+ cmd += runtime.flags
274
+ end
275
+
211
276
  cmd += ["--message", prompt]
212
277
 
213
278
  cmd
214
279
  end
215
280
 
281
+ def parse_response(result, duration:, llm_history_path: nil)
282
+ response = super(result, duration: duration)
283
+ tokens = parse_token_usage(result, llm_history_path: llm_history_path)
284
+
285
+ return response unless tokens
286
+
287
+ Response.new(
288
+ output: response.output,
289
+ exit_code: response.exit_code,
290
+ duration: response.duration,
291
+ provider: response.provider,
292
+ model: response.model,
293
+ tokens: tokens,
294
+ metadata: response.metadata,
295
+ error: response.error
296
+ )
297
+ end
298
+
216
299
  def default_timeout
217
- 600 # Aider can take longer
300
+ 600
301
+ end
302
+
303
+ private
304
+
305
+ TOKEN_COUNT_PATTERN = /\d[\d,]*(?:\.\d+)?[kmb]?/i
306
+
307
+ TOKEN_USAGE_PATTERN =
308
+ /^\s*Tokens:\s*(?<input>#{TOKEN_COUNT_PATTERN})\s+sent(?:,\s*#{TOKEN_COUNT_PATTERN}\s+cache\s+\w+)*,\s*(?<output>#{TOKEN_COUNT_PATTERN})\s+received\.?(?:\s+Cost:\s+.+)?\s*$/i
309
+ FOOTER_COST_PATTERN = /^\s*Cost:\s+.+\s*$/i
310
+ RUN_SHELL_COMMAND_PATTERN = /^\s*Run shell command\?.*$/i
311
+ OUTPUT_STATUS_PATTERN =
312
+ /^\s*(?:Applied edit to|Commit\b|Committing\b|You can use \/undo\b|Added .+ to the chat\.|Removed .+ from the chat\.|Use \/help\b|Create new file\?|Allow edits to\b|Edit the files\?|Run shell command\?).*$/i
313
+ OUTPUT_PATH_PATTERN = /\A(?:\.\.?\/|\/|~\/)[\w.\-\/]+\z/
314
+ OUTPUT_DOTFILE_PATTERN = /\A\.[\w.-]+\z/
315
+ OUTPUT_FILENAME_PATTERN = /\A[\w.-]+\.[A-Za-z][\w.-]*\z/
316
+ COMMON_SHELL_COMMAND_PATTERN =
317
+ /\A(?:git|bundle|ruby|python\d*(?:\.\d+)?|uv|npm|yarn|pnpm|node|bash|sh|zsh|make|rake|rspec|rails|go|pytest|bin\/[\w.-]+|sed|rg|grep|find|ls|cat|cp|mv|rm|mkdir|touch|chmod|chown|docker|kubectl)\z/
318
+ EXECUTOR_LLM_HISTORY_TIMEOUT = 10
319
+
320
+ def generate_llm_history_path
321
+ return "/tmp/aider_llm_history_#{Process.pid}_#{SecureRandom.hex(8)}" if sandboxed_environment?
322
+
323
+ File.join(Dir.tmpdir, "aider_llm_history_#{Process.pid}_#{SecureRandom.hex(8)}")
324
+ end
325
+
326
+ def parse_token_usage(result, llm_history_path:)
327
+ # Aider 0.86.x writes --llm-history-file as conversation text, not JSONL.
328
+ # Prefer the request-local history file when it includes a token report,
329
+ # but fall back to captured command output because the usage summary is
330
+ # printed there during normal runs.
331
+ parse_token_usage_text(safe_read_llm_history(llm_history_path), source: :history) ||
332
+ parse_token_usage_text(result.stdout, source: :output) ||
333
+ parse_token_usage_text(result.stderr, source: :output)
334
+ end
335
+
336
+ def read_llm_history(path)
337
+ return read_executor_llm_history(path) if sandboxed_environment?
338
+ return nil unless path && File.exist?(path) && !File.zero?(path)
339
+
340
+ content = File.read(path)
341
+ return nil if content.strip.empty?
342
+
343
+ content
344
+ end
345
+
346
+ def safe_read_llm_history(path)
347
+ read_llm_history(path)
348
+ rescue => e
349
+ log_debug("llm_history_parse_error", error: e.message)
350
+ nil
351
+ end
352
+
353
+ def parse_token_usage_text(content, source: :output)
354
+ return nil if content.nil? || content.strip.empty?
355
+
356
+ match = if source == :history
357
+ extract_history_token_usage_match(content)
358
+ else
359
+ extract_output_token_usage_match(content)
360
+ end
361
+ return nil unless match
362
+
363
+ input = parse_token_count(match[:input])
364
+ output = parse_token_count(match[:output])
365
+
366
+ {input: input, output: output, total: input + output}
367
+ end
368
+
369
+ def extract_history_token_usage_match(content)
370
+ lines = content.lines
371
+
372
+ lines.each_index.reverse_each do |index|
373
+ match = TOKEN_USAGE_PATTERN.match(lines[index])
374
+ next unless match
375
+ next unless history_token_usage_footer_line?(lines, index)
376
+
377
+ return match
378
+ end
379
+
380
+ nil
381
+ end
382
+
383
+ def extract_output_token_usage_match(content)
384
+ lines = content.lines
385
+
386
+ lines.each_index.reverse_each do |index|
387
+ match = TOKEN_USAGE_PATTERN.match(lines[index])
388
+ next unless match
389
+ next unless output_token_usage_footer_line?(lines, index)
390
+
391
+ return match
392
+ end
393
+
394
+ nil
395
+ end
396
+
397
+ def history_token_usage_footer_line?(lines, index)
398
+ footer_prefix?(lines, index) && footer_suffix?(lines, index)
399
+ end
400
+
401
+ def output_token_usage_footer_line?(lines, index)
402
+ footer_prefix?(lines, index) && output_footer_suffix?(lines, index)
403
+ end
404
+
405
+ def footer_prefix?(lines, index)
406
+ block_start = index
407
+ while block_start.positive? && TOKEN_USAGE_PATTERN.match?(lines[block_start - 1])
408
+ block_start -= 1
409
+ end
410
+
411
+ return false if block_start.zero?
412
+
413
+ lines[block_start - 1].strip.empty?
414
+ end
415
+
416
+ def footer_suffix?(lines, index)
417
+ lines[(index + 1)..].to_a.all? do |line|
418
+ stripped = line.strip
419
+ stripped.empty? || TOKEN_USAGE_PATTERN.match?(line) || FOOTER_COST_PATTERN.match?(line)
420
+ end
421
+ end
422
+
423
+ def output_footer_suffix?(lines, index)
424
+ suffix_lines = lines[(index + 1)..].to_a
425
+ shell_prompt_index = suffix_lines.index { |line| RUN_SHELL_COMMAND_PATTERN.match?(line) }
426
+
427
+ suffix_lines.each_with_index.all? do |line, line_index|
428
+ stripped = line.strip
429
+ stripped.empty? ||
430
+ TOKEN_USAGE_PATTERN.match?(line) ||
431
+ FOOTER_COST_PATTERN.match?(line) ||
432
+ OUTPUT_STATUS_PATTERN.match?(line) ||
433
+ output_path_footer_line?(stripped) ||
434
+ output_command_footer_line?(line, line_index, shell_prompt_index)
435
+ end
436
+ end
437
+
438
+ def output_path_footer_line?(line)
439
+ OUTPUT_PATH_PATTERN.match?(line) ||
440
+ OUTPUT_DOTFILE_PATTERN.match?(line) ||
441
+ OUTPUT_FILENAME_PATTERN.match?(line) ||
442
+ (line.include?("/") && line.match?(/\A[\w.\-\/]+\z/))
443
+ end
444
+
445
+ def output_command_footer_line?(line, line_index, shell_prompt_index)
446
+ return false unless shell_prompt_index && line_index < shell_prompt_index
447
+
448
+ stripped = line.strip
449
+ return false if stripped.end_with?(".", "?", "!")
450
+ return false if stripped.empty?
451
+
452
+ tokens = shell_command_footer_tokens(stripped)
453
+ return false if tokens.empty?
454
+ command = tokens.first
455
+ return false unless command_invocation_token?(command)
456
+ return single_token_command_footer?(command) if tokens.length == 1
457
+ return false unless command_line_token?(command, tokens[1..])
458
+
459
+ tokens[1..].all? { |token| command_argument_token?(token) }
460
+ end
461
+
462
+ def shell_command_footer_tokens(line)
463
+ Shellwords.shellsplit(line.sub(/\A[$>#]\s*/, ""))
464
+ rescue ArgumentError
465
+ []
466
+ end
467
+
468
+ def command_token?(token)
469
+ token.match?(/\A[a-z0-9_][\w.\/~:-]*\z/) && token.match?(/[a-z]/)
470
+ end
471
+
472
+ def command_invocation_token?(token)
473
+ command_token?(token) || executable_path_token?(token)
474
+ end
475
+
476
+ def executable_path_token?(token)
477
+ token.match?(%r{\A(?:\.\.?/|/|~/)[\w.+%:@=-][\w./+%:@~=-]*\z})
478
+ end
479
+
480
+ def command_line_token?(token, arguments)
481
+ command_invocation_token?(token) &&
482
+ (COMMON_SHELL_COMMAND_PATTERN.match?(token) ||
483
+ executable_path_token?(token) ||
484
+ command_footer_shell_like_arguments?(arguments))
485
+ end
486
+
487
+ def single_token_command_footer?(token)
488
+ COMMON_SHELL_COMMAND_PATTERN.match?(token) || executable_path_token?(token)
489
+ end
490
+
491
+ def command_footer_shell_like_arguments?(arguments)
492
+ arguments.any? do |argument|
493
+ argument.match?(%r{\A(?:&&|\|\|?|\||[<>]|>>|&>|2>)\z}) ||
494
+ argument.start_with?("-", "./", "../", "/", "~/") ||
495
+ argument.include?("/")
496
+ end
497
+ end
498
+
499
+ def command_argument_token?(token)
500
+ !token.empty? && !token.match?(/[[:cntrl:]]/)
501
+ end
502
+
503
+ def parse_token_count(value)
504
+ normalized = value.delete(",").downcase
505
+ multiplier = case normalized[-1]
506
+ when "k" then 1_000
507
+ when "m" then 1_000_000
508
+ when "b" then 1_000_000_000
509
+ else 1
510
+ end
511
+ normalized = normalized[0...-1] if multiplier > 1
512
+
513
+ (normalized.to_f * multiplier).round
514
+ end
515
+
516
+ def cleanup_llm_history_file!(path)
517
+ return unless path
518
+
519
+ return cleanup_executor_llm_history_file!(path) if sandboxed_environment?
520
+
521
+ File.delete(path) if File.exist?(path)
522
+ rescue => e
523
+ log_debug("llm_history_cleanup_error", error: e.message)
524
+ nil
525
+ end
526
+
527
+ def validate_runtime_flags!(flags)
528
+ invalid_flags = reserved_runtime_flags(flags)
529
+ return if invalid_flags.empty?
530
+
531
+ raise ArgumentError,
532
+ "Aider provider_runtime.flags cannot override provider-managed flags: " \
533
+ "#{invalid_flags.join(", ")}"
534
+ end
535
+
536
+ def reserved_runtime_flags(flags)
537
+ flags.each_with_index.filter_map do |flag, index|
538
+ next unless reserved_runtime_flag?(flag)
539
+
540
+ if flag == "--llm-history-file" && flags[index + 1]
541
+ "#{flag} #{flags[index + 1]}"
542
+ else
543
+ flag
544
+ end
545
+ end.uniq
546
+ end
547
+
548
+ def reserved_runtime_flag?(flag)
549
+ flag == "--llm-history-file" || flag.start_with?("--llm-history-file=")
550
+ end
551
+
552
+ def read_executor_llm_history(path)
553
+ return nil unless path
554
+
555
+ result = @executor.execute(
556
+ ["sh", "-lc", "if [ -s #{Shellwords.escape(path)} ]; then cat #{Shellwords.escape(path)}; fi"],
557
+ timeout: EXECUTOR_LLM_HISTORY_TIMEOUT
558
+ )
559
+ return nil unless result.success?
560
+
561
+ content = result.stdout
562
+ return nil if content.to_s.strip.empty?
563
+
564
+ content
565
+ end
566
+
567
+ def cleanup_executor_llm_history_file!(path)
568
+ @executor.execute(
569
+ ["sh", "-lc", "rm -f -- #{Shellwords.escape(path)}"],
570
+ timeout: EXECUTOR_LLM_HISTORY_TIMEOUT
571
+ )
572
+ rescue => e
573
+ log_debug("llm_history_cleanup_error", error: e.message)
574
+ nil
218
575
  end
219
576
  end
220
577
  end
@@ -10,6 +10,29 @@ module AgentHarness
10
10
  class Codex < Base
11
11
  SUPPORTED_CLI_VERSION = "0.116.0"
12
12
  SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 0.117.0").freeze
13
+ OAUTH_REFRESH_FAILURE_PATTERNS = [
14
+ /refresh_token_reused/i,
15
+ /failed to refresh token\b.*\b401\b/im,
16
+ /failed to refresh token\b.*unauthorized/im,
17
+ /failed to refresh token\b.*\binvalid_client\b/im,
18
+ /failed to refresh token\b.*\binvalid_grant\b/im,
19
+ /failed to refresh token\b.*invalid.*refresh.*token/im,
20
+ /failed to refresh token\b.*refresh.*token.*invalid/im,
21
+ /your access token could not be refreshed because\b.*\b401\b/im,
22
+ /your access token could not be refreshed because\b.*unauthorized/im,
23
+ /your access token could not be refreshed because\b.*\binvalid_client\b/im,
24
+ /your access token could not be refreshed because\b.*\binvalid_grant\b/im,
25
+ /your access token could not be refreshed because\b.*invalid.*refresh.*token/im,
26
+ /your access token could not be refreshed because\b.*refresh.*token.*invalid/im,
27
+ /your access token could not be refreshed because\s+your refresh token .*already (?:been )?used/im,
28
+ /refresh token .*already (?:been )?used/im
29
+ ].freeze
30
+ OAUTH_REFRESH_TRANSIENT_PATTERNS = [
31
+ /your access token could not be refreshed because\s+(?:the\s+)?auth(?:entication)? service(?:\s+(?:is|was))?\s+(?:temporarily\s+)?unavailable/im,
32
+ /your access token could not be refreshed because .*connection.*error/im,
33
+ /failed to refresh token\b.*connection.*error/im,
34
+ /failed to refresh token\b.*service(?:\s+(?:is|was))?\s+(?:temporarily\s+)?unavailable/im
35
+ ].freeze
13
36
 
14
37
  class << self
15
38
  def provider_name
@@ -171,15 +194,26 @@ module AgentHarness
171
194
  end
172
195
 
173
196
  def error_patterns
174
- COMMON_ERROR_PATTERNS.merge(
175
- auth_expired: COMMON_ERROR_PATTERNS[:auth_expired] + [/\b401\b/, /incorrect.*api.*key/i],
176
- transient: COMMON_ERROR_PATTERNS[:transient] + [/connection.*reset/i],
197
+ {
198
+ rate_limited: COMMON_ERROR_PATTERNS[:rate_limited],
199
+ timeout: [
200
+ /your access token could not be refreshed.*(?:timeout|timed.?out)/im,
201
+ /failed to refresh token\b.*(?:timeout|timed.?out)/im
202
+ ],
203
+ transient: COMMON_ERROR_PATTERNS[:transient] + [
204
+ /connection.*reset/i
205
+ ] + OAUTH_REFRESH_TRANSIENT_PATTERNS,
206
+ auth_expired: COMMON_ERROR_PATTERNS[:auth_expired] + [
207
+ /\b401\b/,
208
+ /incorrect.*api.*key/i
209
+ ] + OAUTH_REFRESH_FAILURE_PATTERNS,
210
+ quota_exceeded: COMMON_ERROR_PATTERNS[:quota_exceeded],
177
211
  sandbox_failure: [
178
212
  /bwrap.*no permissions/i,
179
213
  /no permissions to create a new namespace/i,
180
214
  /unprivileged.*namespace/i
181
215
  ]
182
- )
216
+ }
183
217
  end
184
218
 
185
219
  def auth_status
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.5.9"
4
+ VERSION = "0.6.0"
5
5
  end
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.5.9
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan