agent-harness 0.9.0 → 0.11.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.
@@ -181,6 +181,65 @@ module AgentHarness
181
181
  handle_error(e, prompt: prompt, options: options)
182
182
  end
183
183
 
184
+ # Send a multi-turn chat message via the provider's chat transport.
185
+ #
186
+ # Providers that support chat mode can accept either +conversation:+
187
+ # or +messages:+ as the conversation history payload.
188
+ #
189
+ # Structured streaming events are delivered through three channels:
190
+ # - +on_chat_chunk+ proc (keyword argument)
191
+ # - +observer+ object responding to +on_chat_chunk+
192
+ # - block (yield)
193
+ #
194
+ # When multiple receivers are provided, all receive every event.
195
+ #
196
+ # @param conversation [Array<Hash>, nil] message history
197
+ # @param messages [Array<Hash>, nil] alias for +conversation+
198
+ # @param tools [Array<Hash>, nil] tool/function definitions
199
+ # @param stream [Boolean] whether to stream the response
200
+ # @param on_chat_chunk [Proc, nil] callback for structured streaming events
201
+ # @param observer [#on_chat_chunk, nil] observer receiving streaming events
202
+ # @param options [Hash] additional options
203
+ # @yield [Hash] streaming chunks when stream: true
204
+ # @return [Response] the response
205
+ # @raise [ProviderError] if the provider does not support chat mode
206
+ def send_chat_message(conversation: nil, messages: nil, tools: nil, stream: false,
207
+ on_chat_chunk: nil, observer: nil, **options, &on_chunk)
208
+ unless supports_chat?
209
+ raise ProviderError, "#{name} does not support chat mode"
210
+ end
211
+
212
+ options = normalize_provider_runtime(options)
213
+ runtime = options[:provider_runtime]
214
+ conversation ||= messages
215
+ raise ArgumentError, "conversation or messages is required" unless conversation
216
+ tools = runtime.chat_tools if tools.nil? && runtime&.chat_tools
217
+
218
+ transport = resolve_chat_transport(options)
219
+ messages = format_messages_for_transport(conversation, transport)
220
+ transport_opts = chat_transport_options(runtime, options)
221
+ transport_opts[:on_chat_chunk] = on_chat_chunk if on_chat_chunk
222
+ transport_opts[:observer] = observer if observer
223
+
224
+ response = transport.chat(
225
+ messages: messages,
226
+ tools: tools,
227
+ stream: stream,
228
+ **transport_opts,
229
+ &on_chunk
230
+ )
231
+
232
+ track_tokens(response) if response.tokens
233
+ log_debug("send_chat_message_complete", duration: response.duration, tokens: response.tokens)
234
+
235
+ response
236
+ rescue ProviderError, AuthenticationError, RateLimitError, TimeoutError
237
+ raise
238
+ rescue => e
239
+ last_msg = conversation&.last || messages&.last
240
+ handle_error(e, prompt: (last_msg&.dig(:content) || last_msg&.dig("content")).to_s, options: options)
241
+ end
242
+
184
243
  # Provider name for display
185
244
  #
186
245
  # @return [String] display name
@@ -466,6 +525,89 @@ module AgentHarness
466
525
  end
467
526
  end
468
527
 
528
+ def resolve_chat_transport(options)
529
+ runtime = options[:provider_runtime]
530
+
531
+ # When the runtime specifies chat-specific overrides (base_url, api_key),
532
+ # build a fresh transport instead of reusing the memoized default.
533
+ if runtime && (runtime.chat_base_url || runtime.chat_api_key)
534
+ transport = build_runtime_chat_transport(runtime)
535
+ if transport
536
+ return transport
537
+ end
538
+ end
539
+
540
+ transport = chat_transport
541
+ raise ProviderError, "#{name} chat_transport returned nil" unless transport
542
+
543
+ transport
544
+ end
545
+
546
+ # Build a one-off chat transport from ProviderRuntime overrides.
547
+ #
548
+ # Subclasses that support chat must override this when the runtime
549
+ # carries chat_base_url or chat_api_key so those overrides are
550
+ # actually applied. The base implementation raises to surface the
551
+ # misconfiguration early rather than silently ignoring the overrides.
552
+ def build_runtime_chat_transport(_runtime)
553
+ raise ProviderError,
554
+ "#{name} does not support chat_base_url/chat_api_key overrides on ProviderRuntime"
555
+ end
556
+
557
+ def format_messages_for_transport(conversation, transport)
558
+ normalized = conversation.map { |msg| normalize_transport_message(msg) }
559
+ return normalized unless anthropic_transport?(transport)
560
+ return normalized unless anthropic_conversion_required?(normalized)
561
+
562
+ anthropic = anthropic_conversation(normalized)
563
+ system_messages = anthropic[:system] ? [{role: "system", content: anthropic[:system]}] : []
564
+
565
+ system_messages + anthropic[:messages]
566
+ end
567
+
568
+ def normalize_transport_message(message)
569
+ message.each_with_object({}) do |(key, value), memo|
570
+ memo[key.is_a?(String) ? key.to_sym : key] = value
571
+ end.tap do |normalized|
572
+ normalized[:role] = normalized[:role].to_s if normalized.key?(:role)
573
+ end
574
+ end
575
+
576
+ def anthropic_transport?(transport)
577
+ chat_transport_type == :anthropic || transport.is_a?(TextTransport)
578
+ end
579
+
580
+ def anthropic_conversion_required?(messages)
581
+ messages.any? do |msg|
582
+ msg[:role] == "tool" || msg.key?(:tool_calls)
583
+ end
584
+ end
585
+
586
+ def anthropic_conversation(messages)
587
+ conversation = Conversation.new
588
+
589
+ messages.each do |msg|
590
+ conversation.add_message(
591
+ msg.fetch(:role).to_sym,
592
+ msg[:content],
593
+ tool_calls: msg[:tool_calls],
594
+ tool_call_id: msg[:tool_call_id]
595
+ )
596
+ end
597
+
598
+ conversation.to_anthropic_messages
599
+ end
600
+
601
+ def chat_transport_options(runtime, options)
602
+ opts = {}
603
+ max_tok = options[:chat_max_tokens] || options[:max_tokens] || runtime&.chat_max_tokens
604
+ opts[:max_tokens] = max_tok if max_tok
605
+ model = runtime&.chat_model || runtime&.model
606
+ opts[:model] = model if model
607
+ opts[:temperature] = options[:temperature] if options[:temperature]
608
+ opts
609
+ end
610
+
469
611
  def log_debug(action, **context)
470
612
  @logger&.debug("[AgentHarness::#{self.class.provider_name}] #{action}: #{context.inspect}")
471
613
  end
@@ -139,6 +139,28 @@ module AgentHarness
139
139
  def smoke_test_contract
140
140
  Base::DEFAULT_SMOKE_TEST_CONTRACT
141
141
  end
142
+
143
+ def parse_cli_jsonl_transcript(raw_output, max_events: nil)
144
+ return new.send(:parse_jsonl_output, "") if max_events && max_events <= 0
145
+
146
+ output = max_events ? tail_nonempty_lines(raw_output, limit: max_events).join("\n") : raw_output
147
+
148
+ new.send(:parse_jsonl_output, output)
149
+ end
150
+
151
+ private
152
+
153
+ def tail_nonempty_lines(text, limit:)
154
+ return [] if limit <= 0
155
+
156
+ text.to_s.each_line.each_with_object([]) do |line, lines|
157
+ stripped = line.strip
158
+ next if stripped.empty?
159
+
160
+ lines.shift if lines.size >= limit
161
+ lines << stripped
162
+ end
163
+ end
142
164
  end
143
165
 
144
166
  def name
@@ -603,10 +625,11 @@ module AgentHarness
603
625
  when "turn.completed"
604
626
  turn_usage = build_token_usage(event["usage"])
605
627
  result = event["result"]
628
+ result_parts = result.is_a?(String) ? [result] : extract_task_complete_parts(event)
606
629
  wrapped_completion_without_new_output =
607
630
  pending_turn_usage_source == :wrapped &&
608
631
  pending_turn_usage &&
609
- !result.is_a?(String) &&
632
+ result_parts.nil? &&
610
633
  (turn_usage.nil? || current_turn_parts.empty? || current_turn_parts.equal?(pending_wrapped_output_parts))
611
634
 
612
635
  if wrapped_completion_without_new_output
@@ -663,8 +686,8 @@ module AgentHarness
663
686
  pending_wrapped_same_turn_finalization = false
664
687
  end
665
688
 
666
- if result.is_a?(String)
667
- current_turn_parts = [result]
689
+ if result_parts
690
+ current_turn_parts = result_parts
668
691
  saw_assistant_output = true
669
692
  current_turn_finalized_output = true
670
693
  end
@@ -2,18 +2,19 @@
2
2
 
3
3
  require "digest"
4
4
  require "json"
5
+ require "pathname"
5
6
 
6
7
  module AgentHarness
7
8
  module Providers
8
9
  class GithubCopilot < Base
9
10
  include TokenUsageParsing
10
11
 
11
- PACKAGE_NAME = "@githubnext/github-copilot-cli"
12
- SUPPORTED_CLI_VERSION = "0.1.36"
13
- SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 0.2.0").freeze
14
-
15
12
  MODEL_PATTERN = /^gpt-[\d.o-]+(?:-turbo)?(?:-mini)?$/i
16
13
  JSON_OUTPUT_MIN_VERSION = Gem::Version.new("0.0.422").freeze
14
+ SUBCOMMAND_CLI_MIN_VERSION = Gem::Version.new("0.1.0").freeze
15
+ UNSUPPORTED_SUBCOMMAND_CLI_MESSAGE =
16
+ "github-copilot-cli 0.1.x does not expose a non-interactive send interface; " \
17
+ "the what-the-shell subcommand is interactive and cannot be used by AgentHarness."
17
18
 
18
19
  SMOKE_TEST_CONTRACT = {
19
20
  prompt: "Reply with exactly OK.",
@@ -34,41 +35,22 @@ module AgentHarness
34
35
 
35
36
  def available?
36
37
  executor = AgentHarness.configuration.command_executor
37
- !!executor.which(binary_name)
38
- end
39
-
40
- def installation_contract(version: SUPPORTED_CLI_VERSION)
41
- version = version.strip if version.respond_to?(:strip)
42
- validate_install_version!(version)
43
- package_spec = "#{PACKAGE_NAME}@#{version}".freeze
44
- install_command_prefix = ["npm", "install", "-g", "--ignore-scripts"].freeze
45
- install_command = (install_command_prefix + [package_spec]).freeze
46
- version_requirement = SUPPORTED_CLI_REQUIREMENT.requirements
47
- .map { |op, ver| "#{op} #{ver}".freeze }
48
- .freeze
49
-
50
- contract = {
51
- source: {
52
- type: :npm,
53
- package: PACKAGE_NAME
54
- }.freeze,
55
- install_command_prefix: install_command_prefix,
56
- install_command: install_command,
57
- binary_name: binary_name,
58
- default_version: SUPPORTED_CLI_VERSION,
59
- version: version,
60
- version_requirement: version_requirement,
61
- supported_version_requirement: SUPPORTED_CLI_REQUIREMENT.to_s
62
- }
38
+ return false unless executor.which(binary_name)
63
39
 
64
- contract.each_value do |value|
65
- value.freeze if value.is_a?(String)
66
- end
67
- contract.freeze
40
+ !subcommand_cli_version?(copilot_cli_version(executor: executor))
41
+ rescue
42
+ false
68
43
  end
69
44
 
70
- def install_command(version: SUPPORTED_CLI_VERSION)
71
- installation_contract(version: version)[:install_command]
45
+ def installation_contract(version: nil)
46
+ # The published @githubnext/github-copilot-cli package only has
47
+ # 0.1.x releases, and those expose an interactive subcommand instead
48
+ # of the non-interactive -p prompt path AgentHarness uses.
49
+ nil
50
+ end
51
+
52
+ def install_command(version: nil)
53
+ installation_contract(version: version)&.fetch(:install_command)
72
54
  end
73
55
 
74
56
  def provider_metadata_overrides
@@ -116,6 +98,10 @@ module AgentHarness
116
98
  ]
117
99
  end
118
100
 
101
+ def supports_chat?
102
+ true
103
+ end
104
+
119
105
  def smoke_test_contract
120
106
  SMOKE_TEST_CONTRACT
121
107
  end
@@ -134,26 +120,26 @@ module AgentHarness
134
120
 
135
121
  private
136
122
 
137
- def validate_install_version!(version)
138
- unless version.is_a?(String) && !version.strip.empty?
139
- raise ArgumentError,
140
- "Unsupported GitHub Copilot CLI version #{version.inspect}; " \
141
- "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
142
- end
123
+ def copilot_cli_version(executor:)
124
+ result = executor.execute([binary_name, "--version"], timeout: 5, env: {})
125
+ extract_version(result)
126
+ rescue
127
+ nil
128
+ end
143
129
 
144
- parsed_version = begin
145
- Gem::Version.new(version)
146
- rescue ArgumentError
147
- raise ArgumentError,
148
- "Unsupported GitHub Copilot CLI version #{version.inspect}; " \
149
- "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
150
- end
130
+ def subcommand_cli_version?(version)
131
+ !version.nil? && version >= SUBCOMMAND_CLI_MIN_VERSION
132
+ end
133
+
134
+ def extract_version(result)
135
+ return nil unless result.success?
151
136
 
152
- return if SUPPORTED_CLI_REQUIREMENT.satisfied_by?(parsed_version)
137
+ version_string = [result.stdout, result.stderr].compact.join("\n")[/\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?/]
138
+ return nil if version_string.nil? || version_string.empty?
153
139
 
154
- raise ArgumentError,
155
- "Unsupported GitHub Copilot CLI version #{version.inspect}; " \
156
- "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
140
+ Gem::Version.new(version_string)
141
+ rescue ArgumentError
142
+ nil
157
143
  end
158
144
  end
159
145
 
@@ -194,21 +180,59 @@ module AgentHarness
194
180
  }
195
181
  end
196
182
 
197
- def dangerous_mode_flags(probe_timeout: nil, env: {})
198
- return [] unless supports_json_output_format?(probe_timeout: probe_timeout, env: env)
183
+ def dangerous_mode_flags(probe_timeout: nil, env: {}, version: nil)
184
+ version ||= copilot_cli_version(probe_timeout: probe_timeout, env: env)
185
+ return [] if subcommand_cli_version?(version)
186
+ return [] unless supports_json_output_format?(version: version)
199
187
 
200
188
  ["--allow-all"]
201
189
  end
202
190
 
203
- def supports_sessions?
204
- true
191
+ def supports_sessions?(probe_timeout: nil, env: {}, version: nil)
192
+ legacy_prompt_cli?(version: version, probe_timeout: probe_timeout, env: env)
205
193
  end
206
194
 
207
- def session_flags(session_id)
195
+ def session_flags(session_id, version: nil, probe_timeout: nil, env: {})
208
196
  return [] unless session_id && !session_id.empty?
197
+ return [] unless legacy_prompt_cli?(version: version, probe_timeout: probe_timeout, env: env)
198
+
209
199
  ["--resume", session_id]
210
200
  end
211
201
 
202
+ GITHUB_MODELS_BASE_URL = "https://models.inference.ai.azure.com"
203
+ CHAT_DEFAULT_MODEL = "gpt-4o"
204
+ CHAT_MODELS = %w[gpt-4o gpt-4o-mini gpt-4-turbo].freeze
205
+
206
+ def supports_chat?
207
+ true
208
+ end
209
+
210
+ def chat_models
211
+ CHAT_MODELS
212
+ end
213
+
214
+ def chat_transport
215
+ @chat_transport ||= OpenAICompatibleTransport.new(
216
+ base_url: GITHUB_MODELS_BASE_URL,
217
+ api_key: resolve_chat_api_key,
218
+ model: CHAT_DEFAULT_MODEL,
219
+ logger: @logger
220
+ )
221
+ end
222
+
223
+ def build_runtime_chat_transport(runtime)
224
+ OpenAICompatibleTransport.new(
225
+ base_url: runtime.chat_base_url || GITHUB_MODELS_BASE_URL,
226
+ api_key: runtime.chat_api_key || resolve_chat_api_key,
227
+ model: runtime.chat_model || runtime.model || CHAT_DEFAULT_MODEL,
228
+ logger: @logger
229
+ )
230
+ end
231
+
232
+ def chat_transport_type
233
+ :openai_compatible
234
+ end
235
+
212
236
  def auth_type
213
237
  :oauth
214
238
  end
@@ -221,7 +245,7 @@ module AgentHarness
221
245
  output_format: :text,
222
246
  sandbox_aware: false,
223
247
  uses_subcommand: false,
224
- non_interactive_flag: "-p",
248
+ non_interactive_flag: nil,
225
249
  legitimate_exit_codes: [0],
226
250
  stderr_is_diagnostic: true,
227
251
  parses_rate_limit_reset: false
@@ -324,11 +348,15 @@ module AgentHarness
324
348
  protected
325
349
 
326
350
  def build_command(prompt, options)
327
- cmd = [self.class.binary_name, "-p", prompt]
328
351
  env = options.fetch(:_command_env) { build_env(options) }
329
352
  runtime = options[:provider_runtime]
353
+ version = copilot_cli_version(probe_timeout: options[:_version_probe_timeout], env: env)
354
+
355
+ raise unsupported_subcommand_cli_error if subcommand_cli_version?(version)
330
356
 
331
- if supports_json_output_format?(probe_timeout: options[:_version_probe_timeout], env: env)
357
+ cmd = [self.class.binary_name, "-p", prompt]
358
+
359
+ if supports_json_output_format?(version: version)
332
360
  cmd += ["--output-format", "json"]
333
361
  else
334
362
  # Silent mode suppresses the model/stats decoration older CLIs print in
@@ -340,11 +368,11 @@ module AgentHarness
340
368
  cmd += ["--model", model] if model
341
369
  if options[:dangerous_mode] && supports_dangerous_mode?
342
370
  cmd += programmatic_tool_approval_flags
343
- cmd += dangerous_mode_flags(probe_timeout: options[:_version_probe_timeout], env: env)
371
+ cmd += dangerous_mode_flags(version: version)
344
372
  end
345
373
 
346
374
  if options[:session] && !options[:session].empty?
347
- cmd += session_flags(options[:session])
375
+ cmd += session_flags(options[:session], version: version)
348
376
  end
349
377
 
350
378
  cmd
@@ -385,9 +413,22 @@ module AgentHarness
385
413
  ["--allow-all-tools"]
386
414
  end
387
415
 
388
- def supports_json_output_format?(probe_timeout: nil, env: {})
389
- version = copilot_cli_version(probe_timeout: probe_timeout, env: env)
390
- !version.nil? && version >= JSON_OUTPUT_MIN_VERSION
416
+ def supports_json_output_format?(probe_timeout: nil, env: {}, version: nil)
417
+ version ||= copilot_cli_version(probe_timeout: probe_timeout, env: env)
418
+ !version.nil? && !subcommand_cli_version?(version) && version >= JSON_OUTPUT_MIN_VERSION
419
+ end
420
+
421
+ def legacy_prompt_cli?(probe_timeout: nil, env: {}, version: nil)
422
+ version ||= copilot_cli_version(probe_timeout: probe_timeout, env: env)
423
+ !version.nil? && !subcommand_cli_version?(version)
424
+ end
425
+
426
+ def subcommand_cli_version?(version)
427
+ self.class.send(:subcommand_cli_version?, version)
428
+ end
429
+
430
+ def unsupported_subcommand_cli_error
431
+ ProviderError.new(UNSUPPORTED_SUBCOMMAND_CLI_MESSAGE)
391
432
  end
392
433
 
393
434
  def copilot_cli_version(probe_timeout: nil, env: {})
@@ -443,14 +484,7 @@ module AgentHarness
443
484
  end
444
485
 
445
486
  def extract_version(result)
446
- return nil unless result.success?
447
-
448
- version_string = [result.stdout, result.stderr].compact.join("\n")[/\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?/]
449
- return nil if version_string.nil? || version_string.empty?
450
-
451
- Gem::Version.new(version_string)
452
- rescue ArgumentError
453
- nil
487
+ self.class.send(:extract_version, result)
454
488
  end
455
489
 
456
490
  def parse_jsonl_output(output)
@@ -806,6 +840,28 @@ module AgentHarness
806
840
  def hash_key_present?(value, key)
807
841
  value.is_a?(Hash) && value.key?(key)
808
842
  end
843
+
844
+ def resolve_chat_api_key
845
+ key = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"] || read_copilot_cli_access_token
846
+
847
+ if key.nil? || key.strip.empty?
848
+ raise AuthenticationError.new(
849
+ "Chat mode requires a GitHub token. Set GITHUB_TOKEN or GH_TOKEN, or authenticate the Copilot CLI.",
850
+ provider: :github_copilot
851
+ )
852
+ end
853
+
854
+ key.strip
855
+ end
856
+
857
+ def read_copilot_cli_access_token
858
+ path = Pathname.new(File.join(Dir.home, ".copilot-cli-access-token"))
859
+ return nil unless path.file?
860
+
861
+ path.read
862
+ rescue Errno::ENOENT, Errno::EACCES, IOError
863
+ nil
864
+ end
809
865
  end
810
866
  end
811
867
  end