agent-harness 0.11.3 → 0.13.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: a00dc60851ef15dfa731370add2fb28ac8af98975d61194801afb8a34afc5623
4
- data.tar.gz: 3422826728017a83203ab285d0e733b2958dd77bf8832c9c57dc16eead4e2a5d
3
+ metadata.gz: 5248389a9a7500880e23672e9daad822c4c2038f5424ed2b278ab7c6276cf9a5
4
+ data.tar.gz: 806895feee0bd65477498453d32de90985849a70476560dabd7f0330995db6a2
5
5
  SHA512:
6
- metadata.gz: 32065b807ea92963f0694f28517ca0ad17fe860ac73da96d96a1b51090450cad1324f99bd761f28f55b915c0ca14d59731e10166a4ef7f206b2887a5fdfb1a81
7
- data.tar.gz: 7d4d571e6b98f2769e05ab637938cc41f7ea2ee1bbe2b73b8a8fed9a6c4d6c1a85aca58cf38578b0f81b983818e3df98251bc1e59f0d14e08d69f486429a8ee1
6
+ metadata.gz: 48652585f74b61a2a70a4c118a23cb93977dca88e743ec76cd6c12802cb005cc24c703f25b0994a8b5d423f59114be93c8ab040f957191ac782d6260f6d5ffde
7
+ data.tar.gz: f37ece29dc1dbd311713abd42aeb99363f7bf41ede1e31185fea16c41a312ace62d8247fb3e6034a38ac519797c131000480832921376141a2fe88bbc2b6e822
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.11.3"
2
+ ".": "0.13.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.13.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.12.0...agent-harness/v0.13.0) (2026-05-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * Expose public parse_container_output method on provider interface ([#187](https://github.com/viamin/agent-harness/issues/187)) ([ecdb7ba](https://github.com/viamin/agent-harness/commit/ecdb7bac56e47cf75e1379508cca64a9c7a0ffff))
9
+
10
+ ## [0.12.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.11.3...agent-harness/v0.12.0) (2026-05-01)
11
+
12
+
13
+ ### Features
14
+
15
+ * streaming JSONL event parser for real-time Codex progress tracking ([#184](https://github.com/viamin/agent-harness/issues/184)) ([4905539](https://github.com/viamin/agent-harness/commit/490553992904f39e52028b2140ab99755aad1fb1))
16
+
3
17
  ## [0.11.3](https://github.com/viamin/agent-harness/compare/agent-harness/v0.11.2...agent-harness/v0.11.3) (2026-04-28)
4
18
 
5
19
 
@@ -263,6 +263,33 @@ module AgentHarness
263
263
  cleanup_llm_history_file!(llm_history_path)
264
264
  end
265
265
 
266
+ # Parse raw container output into a Response.
267
+ #
268
+ # Overrides the base implementation to support the
269
+ # +llm_history_path+ option for token usage extraction from
270
+ # Aider's LLM history file.
271
+ #
272
+ # @param stdout [String] captured standard output
273
+ # @param stderr [String] captured standard error
274
+ # @param exit_code [Integer] process exit code
275
+ # @param duration [Float] execution duration in seconds
276
+ # @param options [Hash] additional options
277
+ # @option options [String, nil] :llm_history_path path to LLM history file
278
+ # @return [Response] parsed response
279
+ def parse_container_output(stdout:, stderr: "", exit_code: 0, duration: 0.0, **options)
280
+ result = CommandExecutor::Result.new(
281
+ stdout: stdout,
282
+ stderr: stderr,
283
+ exit_code: exit_code,
284
+ duration: duration
285
+ )
286
+ parse_response(
287
+ result,
288
+ duration: duration,
289
+ llm_history_path: options[:llm_history_path]
290
+ )
291
+ end
292
+
266
293
  protected
267
294
 
268
295
  def build_command(prompt, options)
@@ -265,6 +265,29 @@ module AgentHarness
265
265
  handle_error(e, prompt: (last_msg&.dig(:content) || last_msg&.dig("content")).to_s, options: options)
266
266
  end
267
267
 
268
+ # Parse raw container output into a Response.
269
+ #
270
+ # This is the public interface for parsing CLI output captured from
271
+ # external execution (e.g. Docker containers) without going through
272
+ # send_message. It accepts the same data a CommandExecutor::Result
273
+ # holds and returns an AgentHarness::Response.
274
+ #
275
+ # @param stdout [String] captured standard output
276
+ # @param stderr [String] captured standard error
277
+ # @param exit_code [Integer] process exit code
278
+ # @param duration [Float] execution duration in seconds
279
+ # @param options [Hash] additional provider-specific options
280
+ # @return [Response] parsed response
281
+ def parse_container_output(stdout:, stderr: "", exit_code: 0, duration: 0.0, **options)
282
+ result = CommandExecutor::Result.new(
283
+ stdout: stdout,
284
+ stderr: stderr,
285
+ exit_code: exit_code,
286
+ duration: duration
287
+ )
288
+ parse_response(result, duration: duration)
289
+ end
290
+
268
291
  # Provider name for display
269
292
  #
270
293
  # @return [String] display name
@@ -11,6 +11,11 @@ module AgentHarness
11
11
  include RateLimitResetParsing
12
12
  include McpConfigFileSupport
13
13
 
14
+ StreamingEvent = Struct.new(
15
+ :type, :turn, :tokens, :error_message, :tool_name, :raw_event,
16
+ keyword_init: true
17
+ )
18
+
14
19
  SUPPORTED_CLI_VERSION = "0.116.0"
15
20
  SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 0.117.0").freeze
16
21
  OAUTH_REFRESH_FAILURE_PATTERNS = [
@@ -142,15 +147,31 @@ module AgentHarness
142
147
  end
143
148
 
144
149
  def parse_cli_jsonl_transcript(raw_output, max_events: nil)
145
- return new.send(:parse_jsonl_output, "") if max_events && max_events <= 0
150
+ return parser_instance.send(:parse_jsonl_output, "") if max_events && max_events <= 0
146
151
 
147
152
  output = max_events ? tail_nonempty_lines(raw_output, limit: max_events).join("\n") : raw_output
148
153
 
149
- new.send(:parse_jsonl_output, output)
154
+ parser_instance.send(:parse_jsonl_output, output)
155
+ end
156
+
157
+ # Parse a single Codex JSONL event as it arrives on stdout and classify it
158
+ # for real-time progress tracking. Returns nil for malformed JSON, scalar
159
+ # JSON values, plain-text output, or unsupported event types.
160
+ def parse_streaming_event(line)
161
+ event = JSON.parse(line.to_s)
162
+ return unless event.is_a?(Hash)
163
+
164
+ parser_instance.send(:build_streaming_event, event)
165
+ rescue JSON::ParserError, TypeError
166
+ nil
150
167
  end
151
168
 
152
169
  private
153
170
 
171
+ def parser_instance
172
+ @parser_instance ||= allocate.freeze
173
+ end
174
+
154
175
  def tail_nonempty_lines(text, limit:)
155
176
  return [] if limit <= 0
156
177
 
@@ -507,6 +528,152 @@ module AgentHarness
507
528
 
508
529
  private
509
530
 
531
+ def build_streaming_event(event)
532
+ raw_event, payload, dispatch_type = unwrap_streaming_event(event)
533
+ return unless payload.is_a?(Hash)
534
+
535
+ case dispatch_type
536
+ when "message.delta", "agent_message_delta"
537
+ build_progress_streaming_event(raw_event, payload)
538
+ when "turn.completed", "task_complete", "turn_complete"
539
+ build_turn_complete_streaming_event(raw_event, payload)
540
+ when "turn.failed"
541
+ build_error_streaming_event(raw_event, payload)
542
+ when "item.completed", "response_item", "agent_message"
543
+ build_item_streaming_event(raw_event, payload)
544
+ when "token_count"
545
+ build_token_usage_streaming_event(raw_event, payload)
546
+ end
547
+ end
548
+
549
+ def unwrap_streaming_event(event)
550
+ event_type = event["type"]
551
+
552
+ if event_type == "event_msg"
553
+ payload = event["payload"]
554
+ [event, payload, payload.is_a?(Hash) ? payload["type"] : nil]
555
+ elsif event_type == "response_item"
556
+ # Preserve the original "response_item" dispatch type so
557
+ # build_streaming_event routes to build_item_streaming_event
558
+ # even after unwrapping the inner payload.
559
+ [event, event["payload"], "response_item"]
560
+ else
561
+ [event, event, event_type]
562
+ end
563
+ end
564
+
565
+ def build_progress_streaming_event(raw_event, payload)
566
+ return unless progress_payload?(payload)
567
+
568
+ StreamingEvent.new(
569
+ type: :progress,
570
+ turn: extract_streaming_turn(payload),
571
+ raw_event: raw_event
572
+ )
573
+ end
574
+
575
+ def build_turn_complete_streaming_event(raw_event, payload)
576
+ StreamingEvent.new(
577
+ type: :turn_complete,
578
+ turn: extract_streaming_turn(payload),
579
+ tokens: compact_streaming_tokens(build_token_usage(payload["usage"])),
580
+ raw_event: raw_event
581
+ )
582
+ end
583
+
584
+ def build_error_streaming_event(raw_event, payload)
585
+ StreamingEvent.new(
586
+ type: :error,
587
+ turn: extract_streaming_turn(payload),
588
+ tokens: compact_streaming_tokens(build_token_usage(payload["usage"])),
589
+ error_message: extract_error_message(payload),
590
+ raw_event: raw_event
591
+ )
592
+ end
593
+
594
+ def build_item_streaming_event(raw_event, payload)
595
+ item = payload["item"].is_a?(Hash) ? payload["item"] : payload
596
+
597
+ if tool_use_payload?(item)
598
+ return StreamingEvent.new(
599
+ type: :tool_use,
600
+ turn: extract_streaming_turn(payload),
601
+ tool_name: extract_tool_name(item),
602
+ raw_event: raw_event
603
+ )
604
+ end
605
+
606
+ return unless assistant_message_item?(item) || response_item_assistant_payload?(item) || wrapped_assistant_payload?(item)
607
+
608
+ StreamingEvent.new(
609
+ type: :progress,
610
+ turn: extract_streaming_turn(payload),
611
+ raw_event: raw_event
612
+ )
613
+ end
614
+
615
+ def build_token_usage_streaming_event(raw_event, payload)
616
+ wrapped_token_usage = extract_wrapped_tokens(payload["info"])
617
+ usage = wrapped_token_usage&.fetch(:last, nil) || wrapped_token_usage&.fetch(:total, nil)
618
+ return unless usage
619
+
620
+ StreamingEvent.new(
621
+ type: :token_usage,
622
+ turn: extract_streaming_turn(payload),
623
+ tokens: compact_streaming_tokens(usage),
624
+ raw_event: raw_event
625
+ )
626
+ end
627
+
628
+ def progress_payload?(payload)
629
+ case payload["type"]
630
+ when "message.delta"
631
+ payload["delta"].is_a?(Hash)
632
+ when "agent_message_delta"
633
+ wrapped_assistant_payload?(payload)
634
+ else
635
+ false
636
+ end
637
+ end
638
+
639
+ def tool_use_payload?(item)
640
+ item.is_a?(Hash) && item["type"] == "tool_call"
641
+ end
642
+
643
+ def extract_tool_name(item)
644
+ item["tool_name"] || item["name"] || item.dig("function", "name") || item.dig("call", "name")
645
+ end
646
+
647
+ def extract_streaming_turn(payload)
648
+ value = payload["turn"] || payload["turn_id"] || payload["turn_index"] || payload.dig("context", "turn")
649
+ return value if value.is_a?(Integer)
650
+
651
+ value.to_i if value.is_a?(String) && /\A\d+\z/.match?(value.strip)
652
+ end
653
+
654
+ def compact_streaming_tokens(usage)
655
+ return unless usage
656
+
657
+ {
658
+ input: usage[:input],
659
+ output: usage[:output],
660
+ total: usage[:total]
661
+ }
662
+ end
663
+
664
+ def extract_error_message(payload)
665
+ error = payload["error"]
666
+
667
+ case error
668
+ when String
669
+ error
670
+ when Hash
671
+ error["message"] || error["error"] || error["detail"]
672
+ else
673
+ payload["message"]
674
+ end
675
+ end
676
+
510
677
  def escape_toml_string(val)
511
678
  val.to_s.gsub("\\") { "\\\\" }.gsub('"') { "\\\"" }.gsub("\n") { "\\n" }
512
679
  end
@@ -345,6 +345,33 @@ module AgentHarness
345
345
  handle_error(e, prompt: prompt, options: options)
346
346
  end
347
347
 
348
+ # Parse raw container output into a Response.
349
+ #
350
+ # Overrides the base implementation to support the
351
+ # +json_output_requested+ option, which controls whether JSONL
352
+ # output is parsed for token extraction.
353
+ #
354
+ # @param stdout [String] captured standard output
355
+ # @param stderr [String] captured standard error
356
+ # @param exit_code [Integer] process exit code
357
+ # @param duration [Float] execution duration in seconds
358
+ # @param options [Hash] additional options
359
+ # @option options [Boolean] :json_output_requested whether to parse JSONL output
360
+ # @return [Response] parsed response
361
+ def parse_container_output(stdout:, stderr: "", exit_code: 0, duration: 0.0, **options)
362
+ result = CommandExecutor::Result.new(
363
+ stdout: stdout,
364
+ stderr: stderr,
365
+ exit_code: exit_code,
366
+ duration: duration
367
+ )
368
+ parse_response(
369
+ result,
370
+ duration: duration,
371
+ json_output_requested: options.fetch(:json_output_requested, false)
372
+ )
373
+ end
374
+
348
375
  protected
349
376
 
350
377
  def build_command(prompt, options)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.11.3"
4
+ VERSION = "0.13.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.11.3
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan