agent-harness 0.7.0 → 0.7.1

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: 0a5be3b23b73351341808a0f62fb6e060f226c309ceb508c378ad3d549273e61
4
- data.tar.gz: aa6f754664cd08deeb36f8a54ec3531249a10c80bbe6561706a59db1191afe4f
3
+ metadata.gz: 840999010c09f5e1b70d3dd0a1631cf76e15a13738954cb2f259149f7e0df9c3
4
+ data.tar.gz: 79f321a55d661a7f1a018372b8fea6b1c0f55ce659bfeb62a4937c2fd5852976
5
5
  SHA512:
6
- metadata.gz: d28ce3a2ecc67df9f125c1bb82f51192811fd5cd8394252f9450ec746876d125d2487a65eef6bbf925bdb4438bd74e554213d9ac5aacbbca398357ba32ae1570
7
- data.tar.gz: 63f514add56b1975e1d1a70f1962f8512a9af6eb4427ce893d4fd1c364bc054824fd5208198d61a6ecab0162cd4d382351bb09bcec7c4c0c7f63c5804ac007cd
6
+ metadata.gz: a2092406f7f5f75623eea7e3b4bc3c78c9fd7ffcd6f85b1e90a2e20bdc59f4b5f4bab5d7e9e8dee7fdf8772881e311c02cec7e555ce797c01b1ec7c3f482e023
7
+ data.tar.gz: 3670198c4053fb94c3ec4e990cc4649b19977ccf5d7a5a6b3a95f1acb077f53c50055cd57098eda54b0fa1afaab0d1edff3429d4d0a55c3abf8137c4d846cda2
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.7.0"
2
+ ".": "0.7.1"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.7.0...agent-harness/v0.7.1) (2026-04-15)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **codex:** address remaining json output review feedback ([505e068](https://github.com/viamin/agent-harness/commit/505e068d63fbc5590112ba00faee0d1c62d997e3))
9
+ * **codex:** address review feedback for token usage extraction ([398940e](https://github.com/viamin/agent-harness/commit/398940ecb356ec9e6978d42244ae26295823bb89))
10
+ * **codex:** preserve output-last-message flag values ([7480778](https://github.com/viamin/agent-harness/commit/7480778d6a5d7b9227eec20889bc642eb399d1b5))
11
+
3
12
  ## [0.7.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.6.0...agent-harness/v0.7.0) (2026-04-13)
4
13
 
5
14
 
@@ -174,7 +174,7 @@ module AgentHarness
174
174
  def execution_semantics
175
175
  {
176
176
  prompt_delivery: :arg,
177
- output_format: :text,
177
+ output_format: :json,
178
178
  sandbox_aware: true,
179
179
  uses_subcommand: true,
180
180
  non_interactive_flag: nil,
@@ -275,15 +275,49 @@ module AgentHarness
275
275
  protected
276
276
 
277
277
  def parse_response(result, duration:)
278
- response = super
278
+ output = result.stdout
279
+ error = nil
280
+ tokens = nil
281
+ legitimate = execution_semantics[:legitimate_exit_codes] || [0]
282
+
283
+ unless legitimate.include?(result.exit_code)
284
+ combined = [result.stderr, result.stdout]
285
+ .map { |stream| stream.to_s.strip }
286
+ .reject(&:empty?)
287
+ .join("\n")
288
+ error = combined unless combined.empty?
289
+ end
290
+
291
+ parsed = parse_jsonl_output(output)
292
+ if parsed
293
+ output = parsed[:text].nil? ? output : parsed[:text]
294
+ tokens = parsed[:tokens]
295
+ end
296
+
297
+ response = Response.new(
298
+ output: output,
299
+ exit_code: result.exit_code,
300
+ duration: duration,
301
+ provider: self.class.provider_name,
302
+ model: @config.model,
303
+ tokens: tokens,
304
+ metadata: {
305
+ legitimate_exit_codes: legitimate
306
+ },
307
+ error: error
308
+ )
279
309
 
280
310
  if response.success? && sandbox_failure_detected?(result.stderr)
281
311
  return Response.new(
282
- output: result.stdout,
312
+ output: output,
283
313
  exit_code: 1,
284
314
  duration: duration,
285
315
  provider: self.class.provider_name,
286
316
  model: @config.model,
317
+ tokens: tokens,
318
+ metadata: {
319
+ legitimate_exit_codes: legitimate
320
+ },
287
321
  error: "Sandbox failure detected: #{result.stderr.strip}"
288
322
  )
289
323
  end
@@ -292,8 +326,9 @@ module AgentHarness
292
326
  end
293
327
 
294
328
  def build_command(prompt, options)
295
- cmd = [self.class.binary_name, "exec"]
329
+ cmd = [self.class.binary_name, "exec", "--json"]
296
330
  externally_sandboxed = externally_sandboxed?(options)
331
+ runtime = options[:provider_runtime]
297
332
 
298
333
  # When externally_sandboxed is set, use --dangerously-bypass-approvals-and-sandbox
299
334
  # instead of --full-auto. In the Codex CLI, full_auto is checked first and
@@ -324,8 +359,6 @@ module AgentHarness
324
359
  if options[:session]
325
360
  cmd += session_flags(options[:session])
326
361
  end
327
-
328
- runtime = options[:provider_runtime]
329
362
  if runtime
330
363
  cmd += ["--model", runtime.model] if runtime.model
331
364
  runtime_flags = runtime.flags
@@ -354,6 +387,742 @@ module AgentHarness
354
387
 
355
388
  private
356
389
 
390
+ def parse_jsonl_output(raw_output)
391
+ return nil if raw_output.nil? || raw_output.strip.empty?
392
+
393
+ latest_completed_parts = []
394
+ current_turn_parts = []
395
+ total_input = 0
396
+ total_output = 0
397
+ total_tokens = 0
398
+ has_usage = false
399
+ saw_assistant_output = false
400
+ pending_turn_usage = nil
401
+ pending_turn_usage_source = nil
402
+ pending_wrapped_output_parts = nil
403
+ pending_wrapped_same_turn_finalization = false
404
+ turn_completed = false
405
+ current_turn_finalized_output = false
406
+
407
+ commit_pending_turn = lambda do
408
+ next unless pending_turn_usage
409
+
410
+ total_input += pending_turn_usage[:input]
411
+ total_output += pending_turn_usage[:output]
412
+ total_tokens += pending_turn_usage[:total]
413
+ pending_turn_usage = nil
414
+ pending_turn_usage_source = nil
415
+ pending_wrapped_output_parts = nil
416
+ pending_wrapped_same_turn_finalization = false
417
+ end
418
+
419
+ start_new_turn = lambda do
420
+ next unless turn_completed
421
+
422
+ commit_pending_turn.call
423
+ turn_completed = false
424
+ current_turn_finalized_output = false
425
+ end
426
+
427
+ start_new_finalized_turn = lambda do
428
+ start_new_turn.call
429
+ end
430
+
431
+ start_new_streaming_turn = lambda do
432
+ start_new_turn.call
433
+ next unless pending_turn_usage_source == :wrapped && pending_turn_usage && current_turn_finalized_output
434
+
435
+ latest_completed_parts = current_turn_parts.dup
436
+ commit_pending_turn.call
437
+ current_turn_parts = []
438
+ current_turn_finalized_output = false
439
+ end
440
+
441
+ replace_current_turn_parts = lambda do |parts|
442
+ next if parts.nil?
443
+
444
+ current_turn_parts = parts
445
+ saw_assistant_output = true
446
+ current_turn_finalized_output = true
447
+ end
448
+
449
+ finalize_current_turn = lambda do
450
+ latest_completed_parts = current_turn_parts.dup
451
+ current_turn_parts = []
452
+ turn_completed = true
453
+ current_turn_finalized_output = false
454
+ end
455
+
456
+ finalize_pending_wrapped_turn = lambda do
457
+ next unless pending_turn_usage_source == :wrapped && pending_turn_usage
458
+
459
+ wrapped_output_parts = pending_wrapped_output_parts || current_turn_parts
460
+ latest_completed_parts = wrapped_output_parts.dup
461
+ current_turn_parts = [] if current_turn_parts.equal?(wrapped_output_parts)
462
+ commit_pending_turn.call
463
+ turn_completed = false
464
+ current_turn_finalized_output = false
465
+ end
466
+
467
+ fail_current_turn = lambda do
468
+ latest_completed_parts = []
469
+ current_turn_parts = []
470
+ turn_completed = true
471
+ current_turn_finalized_output = false
472
+ end
473
+
474
+ process_event = lambda do |event|
475
+ next unless event.is_a?(Hash)
476
+
477
+ type = event["type"]
478
+
479
+ case type
480
+ when "message.delta"
481
+ start_new_streaming_turn.call
482
+ appended = append_delta_text(current_turn_parts, event["delta"])
483
+ current_turn_finalized_output = false if appended
484
+ saw_assistant_output ||= appended
485
+ when "agent_message_delta"
486
+ next unless wrapped_assistant_payload?(event)
487
+
488
+ start_new_streaming_turn.call
489
+ appended = append_wrapped_delta_text(current_turn_parts, event)
490
+ current_turn_finalized_output = false if appended
491
+ saw_assistant_output ||= appended
492
+ when "agent_message"
493
+ next unless wrapped_assistant_payload?(event)
494
+
495
+ wrapped_same_turn_finalization =
496
+ pending_turn_usage_source == :wrapped &&
497
+ pending_turn_usage &&
498
+ (
499
+ !current_turn_finalized_output ||
500
+ pending_wrapped_same_turn_finalization
501
+ )
502
+ start_new_turn.call
503
+ replace_current_turn_parts.call(extract_message_content_parts(event))
504
+ pending_wrapped_same_turn_finalization = wrapped_same_turn_finalization
505
+ when "task_complete", "turn_complete"
506
+ completion_parts = extract_task_complete_parts(event)
507
+ next if completion_parts.nil?
508
+
509
+ wrapped_same_turn_finalization =
510
+ pending_turn_usage_source == :wrapped &&
511
+ pending_turn_usage &&
512
+ (
513
+ !current_turn_finalized_output ||
514
+ pending_wrapped_same_turn_finalization
515
+ )
516
+ start_new_turn.call
517
+ replace_current_turn_parts.call(completion_parts)
518
+ pending_wrapped_same_turn_finalization = wrapped_same_turn_finalization
519
+ when "item.completed"
520
+ item = event["item"]
521
+ next unless item.is_a?(Hash)
522
+ next unless assistant_message_item?(item)
523
+
524
+ start_new_finalized_turn.call
525
+ replace_current_turn_parts.call(extract_message_content_parts(item))
526
+ pending_wrapped_same_turn_finalization =
527
+ pending_turn_usage_source == :wrapped && pending_turn_usage
528
+ when "turn.completed"
529
+ turn_usage = build_token_usage(event["usage"])
530
+ result = event["result"]
531
+ wrapped_completion_without_new_output =
532
+ pending_turn_usage_source == :wrapped &&
533
+ pending_turn_usage &&
534
+ !result.is_a?(String) &&
535
+ (turn_usage.nil? || current_turn_parts.empty? || current_turn_parts.equal?(pending_wrapped_output_parts))
536
+
537
+ if wrapped_completion_without_new_output
538
+ if pending_wrapped_output_parts && !current_turn_parts.empty? && !current_turn_parts.equal?(pending_wrapped_output_parts)
539
+ commit_pending_turn.call
540
+ finalize_current_turn.call
541
+ if turn_usage
542
+ has_usage = true
543
+ pending_turn_usage = turn_usage
544
+ pending_turn_usage_source = :turn_completed
545
+ pending_wrapped_same_turn_finalization = false
546
+ end
547
+ next
548
+ end
549
+
550
+ wrapped_output_parts = pending_wrapped_output_parts || current_turn_parts
551
+ latest_completed_parts = wrapped_output_parts.dup
552
+ current_turn_parts = [] if current_turn_parts.equal?(wrapped_output_parts)
553
+ commit_pending_turn.call
554
+ if turn_usage
555
+ has_usage = true
556
+ total_input += turn_usage[:input]
557
+ total_output += turn_usage[:output]
558
+ total_tokens += turn_usage[:total]
559
+ end
560
+ turn_completed = true
561
+ current_turn_finalized_output = false
562
+ next
563
+ end
564
+
565
+ same_streaming_wrapped_turn =
566
+ pending_turn_usage_source == :wrapped &&
567
+ pending_wrapped_output_parts&.equal?(current_turn_parts) &&
568
+ !current_turn_finalized_output
569
+ same_wrapped_turn = pending_turn_usage_source == :wrapped &&
570
+ same_turn_usage?(pending_turn_usage, turn_usage) &&
571
+ (
572
+ same_turn_output?(current_turn_parts, current_turn_finalized_output, result) ||
573
+ same_streaming_wrapped_turn
574
+ )
575
+
576
+ finalize_pending_wrapped_turn.call unless same_wrapped_turn
577
+
578
+ if turn_completed && !same_wrapped_turn
579
+ commit_pending_turn.call
580
+ turn_completed = false
581
+ end
582
+
583
+ if turn_usage
584
+ has_usage = true
585
+ turn_usage = merge_same_turn_usage(pending_turn_usage, turn_usage) if same_wrapped_turn
586
+ pending_turn_usage = turn_usage
587
+ pending_turn_usage_source = :turn_completed
588
+ pending_wrapped_same_turn_finalization = false
589
+ end
590
+
591
+ if result.is_a?(String)
592
+ current_turn_parts = [result]
593
+ saw_assistant_output = true
594
+ current_turn_finalized_output = true
595
+ end
596
+
597
+ finalize_current_turn.call
598
+ when "turn.failed"
599
+ turn_usage = build_token_usage(event["usage"])
600
+ same_streaming_wrapped_turn =
601
+ pending_turn_usage_source == :wrapped &&
602
+ pending_wrapped_output_parts&.equal?(current_turn_parts) &&
603
+ !current_turn_finalized_output
604
+ same_finalized_wrapped_turn =
605
+ pending_turn_usage_source == :wrapped &&
606
+ pending_wrapped_same_turn_finalization &&
607
+ current_turn_finalized_output
608
+ same_wrapped_turn = pending_turn_usage_source == :wrapped &&
609
+ same_turn_usage?(pending_turn_usage, turn_usage) &&
610
+ (
611
+ pending_wrapped_output_parts&.equal?(current_turn_parts) ||
612
+ same_streaming_wrapped_turn ||
613
+ same_finalized_wrapped_turn
614
+ )
615
+
616
+ finalize_pending_wrapped_turn.call unless same_wrapped_turn
617
+
618
+ if turn_completed && !same_wrapped_turn
619
+ commit_pending_turn.call
620
+ turn_completed = false
621
+ end
622
+
623
+ if turn_usage
624
+ has_usage = true
625
+ turn_usage = merge_same_turn_usage(pending_turn_usage, turn_usage) if same_wrapped_turn
626
+ pending_turn_usage = turn_usage
627
+ pending_turn_usage_source = :turn_completed
628
+ pending_wrapped_same_turn_finalization = false
629
+ end
630
+
631
+ fail_current_turn.call
632
+ when "event_msg"
633
+ payload = event["payload"]
634
+ next unless payload.is_a?(Hash)
635
+
636
+ case payload["type"]
637
+ when "agent_message_delta"
638
+ next unless wrapped_assistant_payload?(payload)
639
+
640
+ start_new_streaming_turn.call
641
+ appended = append_wrapped_delta_text(current_turn_parts, payload)
642
+ current_turn_finalized_output = false if appended
643
+ saw_assistant_output ||= appended
644
+ when "agent_message"
645
+ next unless wrapped_assistant_payload?(payload)
646
+
647
+ wrapped_same_turn_finalization =
648
+ pending_turn_usage_source == :wrapped &&
649
+ pending_turn_usage &&
650
+ (
651
+ !current_turn_finalized_output ||
652
+ pending_wrapped_same_turn_finalization
653
+ )
654
+ start_new_turn.call
655
+ replace_current_turn_parts.call(extract_message_content_parts(payload))
656
+ pending_wrapped_same_turn_finalization = wrapped_same_turn_finalization
657
+ when "task_complete", "turn_complete"
658
+ completion_parts = extract_task_complete_parts(payload)
659
+ next if completion_parts.nil?
660
+
661
+ wrapped_same_turn_finalization =
662
+ pending_turn_usage_source == :wrapped &&
663
+ pending_turn_usage &&
664
+ (
665
+ !current_turn_finalized_output ||
666
+ pending_wrapped_same_turn_finalization
667
+ )
668
+ start_new_turn.call
669
+ replace_current_turn_parts.call(completion_parts)
670
+ pending_wrapped_same_turn_finalization = wrapped_same_turn_finalization
671
+ when "token_count"
672
+ wrapped_token_usage = extract_wrapped_tokens(payload["info"])
673
+ if wrapped_token_usage
674
+ has_usage = true
675
+ if wrapped_token_usage_starts_new_turn?(pending_turn_usage, pending_turn_usage_source, turn_completed, wrapped_token_usage)
676
+ commit_pending_turn.call
677
+ turn_completed = false
678
+ end
679
+ pending_turn_usage, pending_turn_usage_source = merge_wrapped_turn_usage(
680
+ pending_turn_usage,
681
+ pending_turn_usage_source,
682
+ wrapped_token_usage
683
+ )
684
+ pending_wrapped_output_parts =
685
+ (pending_turn_usage_source == :wrapped) ? current_turn_parts : nil
686
+ end
687
+ end
688
+ when "response_item"
689
+ payload = event["payload"]
690
+ next unless payload.is_a?(Hash) && response_item_assistant_payload?(payload)
691
+
692
+ start_new_finalized_turn.call
693
+ replace_current_turn_parts.call(extract_message_content_parts(payload))
694
+ pending_wrapped_same_turn_finalization =
695
+ pending_turn_usage_source == :wrapped && pending_turn_usage
696
+ end
697
+ end
698
+
699
+ raw_output.each_line do |line|
700
+ line = line.strip
701
+ next if line.empty?
702
+
703
+ begin
704
+ event = JSON.parse(line)
705
+ rescue JSON::ParserError
706
+ next
707
+ end
708
+
709
+ process_event.call(event)
710
+ end
711
+
712
+ commit_pending_turn.call
713
+ final_parts = current_turn_parts.empty? ? latest_completed_parts : current_turn_parts
714
+ text = if final_parts.empty?
715
+ (turn_completed && saw_assistant_output) ? "" : nil
716
+ else
717
+ final_parts.join
718
+ end
719
+
720
+ {
721
+ text: text,
722
+ tokens: has_usage ? {
723
+ input: total_input,
724
+ output: total_output,
725
+ total: total_tokens
726
+ } : nil
727
+ }
728
+ rescue
729
+ nil
730
+ end
731
+
732
+ def append_delta_text(parts, delta)
733
+ return false unless delta.is_a?(Hash)
734
+
735
+ delta_parts = extract_delta_content_parts(delta)
736
+ return false if delta_parts.nil?
737
+
738
+ appended = false
739
+ delta_parts.each do |part|
740
+ next if part.empty?
741
+
742
+ parts << part
743
+ appended = true
744
+ end
745
+
746
+ appended
747
+ end
748
+
749
+ def append_wrapped_delta_text(parts, payload)
750
+ delta_parts = extract_wrapped_delta_parts(payload)
751
+ return false if delta_parts.nil?
752
+
753
+ appended = false
754
+ delta_parts.each do |part|
755
+ next if part.empty?
756
+
757
+ parts << part
758
+ appended = true
759
+ end
760
+
761
+ appended
762
+ end
763
+
764
+ def assistant_message_item?(item)
765
+ item_role = item["role"]
766
+ item_type = item["type"]
767
+ item_item_type = item["item_type"]
768
+ message_shaped_item =
769
+ (
770
+ message_item_type?(item_type) ||
771
+ item_type == "agent_message"
772
+ ) && assistant_message_item_type?(item_item_type)
773
+
774
+ (
775
+ item_role == "assistant" && message_shaped_item
776
+ ) || (
777
+ item_role.nil? && message_shaped_item && (
778
+ item_type == "agent_message" ||
779
+ item_item_type == "assistant_message"
780
+ )
781
+ )
782
+ end
783
+
784
+ def wrapped_assistant_payload?(payload)
785
+ role = payload["role"]
786
+ item_type = payload["item_type"]
787
+
788
+ assistant_message_item_type?(item_type) &&
789
+ (role == "assistant" || role.nil?)
790
+ end
791
+
792
+ def response_item_assistant_payload?(payload)
793
+ payload_type = payload["type"]
794
+ payload_role = payload["role"]
795
+ payload_item_type = payload["item_type"]
796
+ assistant_message_type = payload_type == "assistant_message"
797
+
798
+ return false unless assistant_message_item_type?(payload_item_type)
799
+
800
+ ((message_item_type?(payload_type) || payload_type == "agent_message" || assistant_message_type) && payload_role == "assistant") ||
801
+ (payload_type == "agent_message" && (
802
+ payload_role == "assistant" ||
803
+ (payload_role.nil? && assistant_message_item_type?(payload_item_type))
804
+ )) ||
805
+ (
806
+ assistant_message_type && (
807
+ payload_role == "assistant" ||
808
+ (payload_role.nil? && assistant_message_item_type?(payload_item_type))
809
+ )
810
+ ) ||
811
+ (
812
+ payload_role.nil? &&
813
+ message_item_type?(payload_type) &&
814
+ payload_item_type == "assistant_message"
815
+ )
816
+ end
817
+
818
+ def assistant_message_item_type?(item_type)
819
+ item_type.nil? || item_type == "assistant_message"
820
+ end
821
+
822
+ def message_item_type?(item_type)
823
+ item_type.nil? || item_type == "message"
824
+ end
825
+
826
+ def extract_message_content_parts(item)
827
+ item_text = item["text"]
828
+ return [item_text] if item_text.is_a?(String) && !item_text.empty?
829
+
830
+ item_message = item["message"]
831
+ return [item_message] if item_message.is_a?(String) && !item_message.empty?
832
+
833
+ if item_text.is_a?(String)
834
+ return extract_fallback_content_parts(item, item_text)
835
+ end
836
+
837
+ if item_message.is_a?(String)
838
+ return extract_fallback_content_parts(item, item_message)
839
+ end
840
+
841
+ item_content = item["content"]
842
+ return nil unless item_content.is_a?(Array)
843
+
844
+ extract_content_parts(item_content)
845
+ end
846
+
847
+ def extract_fallback_content_parts(item, empty_value)
848
+ item_content = item["content"]
849
+ return [empty_value] unless item_content.is_a?(Array)
850
+
851
+ content_parts = extract_content_parts(item_content)
852
+ content_parts.nil? ? [empty_value] : content_parts
853
+ end
854
+
855
+ def extract_wrapped_delta_parts(payload)
856
+ delta = payload["delta"]
857
+ if delta.is_a?(Hash)
858
+ delta_parts = extract_delta_content_parts(delta)
859
+ return delta_parts unless delta_parts.nil?
860
+ end
861
+
862
+ extract_delta_content_parts(payload)
863
+ end
864
+
865
+ def extract_task_complete_parts(payload)
866
+ last_agent_message = payload["last_agent_message"]
867
+ return [last_agent_message] if last_agent_message.is_a?(String)
868
+ return nil unless last_agent_message.is_a?(Hash)
869
+ return nil unless completed_assistant_message_payload?(last_agent_message)
870
+
871
+ extract_message_content_parts(last_agent_message)
872
+ end
873
+
874
+ def completed_assistant_message_payload?(payload)
875
+ payload_role = payload["role"]
876
+ payload_type = payload["type"]
877
+ payload_item_type = payload["item_type"]
878
+ message_shaped_payload =
879
+ (
880
+ message_item_type?(payload_type) ||
881
+ payload_type == "agent_message" ||
882
+ payload_type == "assistant_message"
883
+ ) && assistant_message_item_type?(payload_item_type)
884
+
885
+ (
886
+ payload_role == "assistant" && message_shaped_payload
887
+ ) || (
888
+ payload_role.nil? && message_shaped_payload && (
889
+ payload_type.nil? ||
890
+ payload_type == "agent_message" ||
891
+ payload_type == "assistant_message" ||
892
+ payload_item_type == "assistant_message"
893
+ )
894
+ )
895
+ end
896
+
897
+ def extract_delta_content_parts(item)
898
+ direct_parts = extract_message_content_parts(item)
899
+ return direct_parts unless direct_parts == [""]
900
+
901
+ item_content = item["content"]
902
+ return direct_parts unless item_content.is_a?(Array)
903
+
904
+ content_parts = extract_content_parts(item_content)
905
+ content_parts.nil? ? direct_parts : content_parts
906
+ end
907
+
908
+ def output_text_block?(block)
909
+ block_type = block["type"]
910
+
911
+ block_type.nil? || block_type == "output_text" || block_type == "output_text_delta"
912
+ end
913
+
914
+ def extract_content_parts(item_content)
915
+ completed_parts = []
916
+ extracted_content = false
917
+
918
+ item_content.each do |block|
919
+ next unless block.is_a?(Hash)
920
+ next unless output_text_block?(block)
921
+
922
+ block_text = block["text"]
923
+ next unless block_text.is_a?(String)
924
+
925
+ extracted_content = true
926
+ completed_parts << block_text
927
+ end
928
+
929
+ extracted_content ? completed_parts : nil
930
+ end
931
+
932
+ def extract_wrapped_tokens(info)
933
+ return unless info.is_a?(Hash)
934
+
935
+ last_usage = build_token_usage(info["last_token_usage"])
936
+ total_usage = build_token_usage(info["total_token_usage"])
937
+
938
+ return unless last_usage || total_usage
939
+
940
+ {last: last_usage, total: total_usage}
941
+ end
942
+
943
+ def token_usage_fields_present?(usage)
944
+ usage.is_a?(Hash) && (
945
+ !parse_token_count(usage["input_tokens"]).nil? ||
946
+ !parse_token_count(usage["cached_input_tokens"]).nil? ||
947
+ !parse_token_count(usage["output_tokens"]).nil? ||
948
+ !parse_token_count(usage["total_tokens"]).nil?
949
+ )
950
+ end
951
+
952
+ def build_token_usage(usage)
953
+ return unless token_usage_fields_present?(usage)
954
+
955
+ input_value = parse_token_count(usage["input_tokens"])
956
+ cached_input_value = parse_token_count(usage["cached_input_tokens"])
957
+ output_value = parse_token_count(usage["output_tokens"])
958
+ total_value = parse_token_count(usage["total_tokens"])
959
+
960
+ input = (input_value || 0) + (cached_input_value || 0)
961
+ output = output_value || 0
962
+ total = total_value || (input + output)
963
+
964
+ {
965
+ input: input,
966
+ output: output,
967
+ total: total,
968
+ input_reported: !input_value.nil? || !cached_input_value.nil?,
969
+ output_reported: !output_value.nil?,
970
+ total_reported: !total_value.nil?
971
+ }
972
+ end
973
+
974
+ def merge_wrapped_turn_usage(existing_usage, existing_source, wrapped_token_usage)
975
+ total_usage = wrapped_token_usage[:total]
976
+ last_usage = wrapped_token_usage[:last]
977
+
978
+ if existing_source == :turn_completed
979
+ replacement_usage = merged_wrapped_usage(existing_usage, existing_source, last_usage, total_usage)
980
+ return [existing_usage, existing_source] unless replacement_usage
981
+
982
+ return [merge_same_turn_usage(existing_usage, replacement_usage), :turn_completed]
983
+ end
984
+
985
+ merged_usage = merged_wrapped_usage(existing_usage, existing_source, last_usage, total_usage)
986
+ [merged_usage, :wrapped]
987
+ end
988
+
989
+ def merged_wrapped_usage(existing_usage, existing_source, last_usage, total_usage)
990
+ if last_usage
991
+ replacement_usage = last_usage
992
+ if total_usage && same_turn_usage?(replacement_usage, total_usage)
993
+ replacement_usage = merge_same_turn_usage(replacement_usage, total_usage)
994
+ end
995
+
996
+ return replacement_usage unless existing_source == :wrapped && existing_usage
997
+
998
+ merged_usage = add_token_usage(existing_usage, last_usage)
999
+ if total_usage && same_turn_usage?(merged_usage, total_usage)
1000
+ return merge_same_turn_usage(merged_usage, total_usage)
1001
+ end
1002
+
1003
+ return replacement_usage if total_usage && same_turn_usage?(last_usage, total_usage)
1004
+
1005
+ return merged_usage
1006
+ end
1007
+
1008
+ total_usage
1009
+ end
1010
+
1011
+ def wrapped_token_usage_starts_new_turn?(existing_usage, existing_source, turn_completed, wrapped_token_usage)
1012
+ return false unless turn_completed && existing_source == :turn_completed && existing_usage
1013
+
1014
+ candidate_usage = wrapped_token_usage[:total] || wrapped_token_usage[:last]
1015
+ return false unless candidate_usage
1016
+
1017
+ return false if same_turn_usage?(existing_usage, candidate_usage)
1018
+
1019
+ existing_detailed = existing_usage[:input_reported] && existing_usage[:output_reported]
1020
+ candidate_detailed = candidate_usage[:input_reported] && candidate_usage[:output_reported]
1021
+ existing_total_only = existing_usage[:total_reported] && !existing_detailed
1022
+ candidate_total_only = candidate_usage[:total_reported] && !candidate_detailed
1023
+
1024
+ return true if existing_detailed && candidate_detailed
1025
+ return true if existing_total_only && candidate_detailed
1026
+
1027
+ existing_total_only && candidate_total_only
1028
+ end
1029
+
1030
+ def add_token_usage(left, right)
1031
+ {
1032
+ input: left[:input] + right[:input],
1033
+ output: left[:output] + right[:output],
1034
+ total: left[:total] + right[:total],
1035
+ input_reported: left[:input_reported] || right[:input_reported],
1036
+ output_reported: left[:output_reported] || right[:output_reported],
1037
+ total_reported: left[:total_reported] || right[:total_reported]
1038
+ }
1039
+ end
1040
+
1041
+ def merge_same_turn_usage(left, right)
1042
+ return right unless left
1043
+ return left unless right
1044
+
1045
+ merged_input_reported = left[:input_reported] || right[:input_reported]
1046
+ merged_output_reported = left[:output_reported] || right[:output_reported]
1047
+ merged_total_reported = left[:total_reported] || right[:total_reported]
1048
+
1049
+ input = if right[:input_reported]
1050
+ right[:input]
1051
+ elsif left[:input_reported]
1052
+ left[:input]
1053
+ else
1054
+ 0
1055
+ end
1056
+
1057
+ output = if right[:output_reported]
1058
+ right[:output]
1059
+ elsif left[:output_reported]
1060
+ left[:output]
1061
+ else
1062
+ 0
1063
+ end
1064
+
1065
+ total = if right[:total_reported]
1066
+ right[:total]
1067
+ elsif left[:total_reported]
1068
+ left[:total]
1069
+ else
1070
+ input + output
1071
+ end
1072
+
1073
+ {
1074
+ input: input,
1075
+ output: output,
1076
+ total: total,
1077
+ input_reported: merged_input_reported,
1078
+ output_reported: merged_output_reported,
1079
+ total_reported: merged_total_reported
1080
+ }
1081
+ end
1082
+
1083
+ def same_turn_usage?(left, right)
1084
+ return false unless left && right
1085
+
1086
+ detailed_usage_matches = left[:input_reported] &&
1087
+ right[:input_reported] &&
1088
+ left[:output_reported] &&
1089
+ right[:output_reported]
1090
+ return left[:input] == right[:input] && left[:output] == right[:output] if detailed_usage_matches
1091
+
1092
+ mixed_total_match = (
1093
+ left[:input_reported] &&
1094
+ left[:output_reported] &&
1095
+ right[:total_reported]
1096
+ ) || (
1097
+ right[:input_reported] &&
1098
+ right[:output_reported] &&
1099
+ left[:total_reported]
1100
+ )
1101
+ return left[:total] == right[:total] if mixed_total_match
1102
+
1103
+ left[:total_reported] && right[:total_reported] && left[:total] == right[:total]
1104
+ end
1105
+
1106
+ def same_turn_output?(current_turn_parts, current_turn_finalized_output, result)
1107
+ return true if current_turn_parts.empty?
1108
+ return false unless current_turn_finalized_output
1109
+ return true unless result.is_a?(String)
1110
+
1111
+ current_turn_parts.join == result
1112
+ end
1113
+
1114
+ def parse_token_count(value)
1115
+ case value
1116
+ when Integer
1117
+ value if value >= 0
1118
+ when String
1119
+ stripped = value.strip
1120
+ return nil unless /\A\d+\z/.match?(stripped)
1121
+
1122
+ stripped.to_i
1123
+ end
1124
+ end
1125
+
357
1126
  def externally_sandboxed?(options)
358
1127
  if options.key?(:externally_sandboxed)
359
1128
  !!options[:externally_sandboxed]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.1"
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.7.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan