agent-harness 0.14.1 → 0.16.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: 972e7e3144da1a59c0a25dbf4668766d07693870dde63c74dece4842c960dfe5
4
- data.tar.gz: 3988ed1d19d61ce9144224302c0246c2c32442abdfa1d1c9bc4616abcb4e11c3
3
+ metadata.gz: 12fc4554399bc5663e5fcde0747f0ba4f25b92499633fb9e6916f5f205f9f05e
4
+ data.tar.gz: 2c5f3e1235a2c648c246988d684f9479a2151cbd6fb905e8774581115ce472ef
5
5
  SHA512:
6
- metadata.gz: 97d598d30445ef7617c172b692d43f3c8d8c896b9c12f28f7f0e56f822128e298312129bdae67adbbfc81eb407686587db677787bda70a2292c3a1f07aea9a60
7
- data.tar.gz: 863739d9ace22d47b37799e44b36c19cacf221a4042947a627525f542838ccaafb933e03c3e0c10141c0d30971283046791b44d4ca8cc7c84c4a0e50184010a8
6
+ metadata.gz: e0f815d6b6fb68e0d4b1307d0a4d594022cd814a213afbea4a87db1759c8a00513ec43b95a144698f5a3dca05e91e71474ee239865ad9b8174033c0694e7e838
7
+ data.tar.gz: dfa7e99ee7c88cc4fce145dcbc431419d61a86dac4f9ebf5e3b6045c92729bb640b7dbbab5d1b597439039f0381d6f12f05de59731e20f282e8245c8dc15e052
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.14.1"
2
+ ".": "0.16.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.16.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.15.0...agent-harness/v0.16.0) (2026-05-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * Add plan-only / dry-run API returning command+env without execution ([#192](https://github.com/viamin/agent-harness/issues/192)) ([0e6a105](https://github.com/viamin/agent-harness/commit/0e6a1053515e0495900b67c5845a2f95c571f055))
9
+
10
+ ## [0.15.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.14.1...agent-harness/v0.15.0) (2026-05-03)
11
+
12
+
13
+ ### Features
14
+
15
+ * pre-flight connectivity check API for provider health verification ([#185](https://github.com/viamin/agent-harness/issues/185)) ([3ad6a2f](https://github.com/viamin/agent-harness/commit/3ad6a2ffbfe84b2271e4de968fa096276724e63c))
16
+
3
17
  ## [0.14.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.14.0...agent-harness/v0.14.1) (2026-05-03)
4
18
 
5
19
 
@@ -186,6 +186,7 @@ module AgentHarness
186
186
  klass = registry.get(provider_name)
187
187
  provider_instance = build_provider(provider_name, klass, executor: executor)
188
188
  host_preflight_allowed = host_preflight_allowed?(executor: executor, provider_runtime: provider_runtime)
189
+ provider_preflight_allowed = provider_preflight_allowed?(executor: executor)
189
190
 
190
191
  auth_degraded = false
191
192
  if host_preflight_allowed
@@ -273,6 +274,26 @@ module AgentHarness
273
274
  )
274
275
  end
275
276
 
277
+ # Only run the provider preflight in host contexts. The preflight
278
+ # hook (e.g. Codex's Net::HTTP probe) executes in the Ruby host
279
+ # process, so its network view may not match a containerised or
280
+ # remote executor. Skipping it avoids marking a provider unhealthy
281
+ # when only the host cannot reach the endpoint.
282
+ if provider_preflight_allowed
283
+ preflight_env = build_preflight_env(provider_instance, provider_runtime)
284
+ preflight = provider_instance.preflight_check(env: preflight_env, timeout: timeout)
285
+ unless preflight[:healthy]
286
+ return build_result(
287
+ name: provider_name,
288
+ status: "error",
289
+ message: preflight[:reason] || "Preflight check failed",
290
+ start_time: start_time,
291
+ error_category: normalize_preflight_error_category(preflight[:error_category]),
292
+ check: :preflight
293
+ )
294
+ end
295
+ end
296
+
276
297
  smoke_contract = provider_instance.smoke_test_contract
277
298
  # Explicitly handle missing smoke-test contract when no custom smoke_test implementation
278
299
  if smoke_contract.nil? && !provider_overrides_method?(provider_instance, :smoke_test)
@@ -363,15 +384,23 @@ module AgentHarness
363
384
 
364
385
  def host_preflight_allowed?(executor:, provider_runtime: nil)
365
386
  effective_executor = executor || AgentHarness.configuration.command_executor
366
- # Skip host preflight only when provider runtime has environment/config overrides
367
- # that could conflict with host-level checks (env, base_url, api_provider, unset_env)
368
387
  if provider_runtime
369
388
  runtime = ProviderRuntime.wrap(provider_runtime)
370
- return false if runtime && (!runtime.env.empty? || !runtime.unset_env.empty? || runtime.base_url || runtime.api_provider)
389
+ return false if runtime_sensitive_host_overrides?(runtime)
371
390
  end
391
+
392
+ provider_preflight_allowed?(executor: effective_executor)
393
+ end
394
+
395
+ def provider_preflight_allowed?(executor:)
396
+ effective_executor = executor || AgentHarness.configuration.command_executor
372
397
  effective_executor.is_a?(CommandExecutor) && !effective_executor.is_a?(DockerCommandExecutor)
373
398
  end
374
399
 
400
+ def runtime_sensitive_host_overrides?(runtime)
401
+ runtime && (!runtime.env.empty? || !runtime.unset_env.empty? || runtime.base_url || runtime.api_provider)
402
+ end
403
+
375
404
  def effective_check_timeout(provider_name, base_timeout)
376
405
  registry = Providers::Registry.instance
377
406
  return base_timeout unless registry.registered?(provider_name)
@@ -410,6 +439,25 @@ module AgentHarness
410
439
  end
411
440
  end
412
441
 
442
+ def normalize_preflight_error_category(category)
443
+ case category&.to_sym
444
+ when :installation
445
+ :installation
446
+ when :auth_expired, :authentication
447
+ :authentication
448
+ when :rate_limited, :rate_limit
449
+ :rate_limit
450
+ when :quota_exceeded, :quota
451
+ :quota
452
+ when :timeout
453
+ :timeout
454
+ when :configuration
455
+ :configuration
456
+ else
457
+ :transient
458
+ end
459
+ end
460
+
413
461
  def installation_failure_message?(message)
414
462
  message.to_s.match?(/(not found in PATH|command not found|No such file or directory|is not installed)/i)
415
463
  end
@@ -453,6 +501,15 @@ module AgentHarness
453
501
  provider
454
502
  end
455
503
 
504
+ def build_preflight_env(provider_instance, provider_runtime)
505
+ return {} unless provider_instance.respond_to?(:build_env, true)
506
+
507
+ runtime = ProviderRuntime.wrap(provider_runtime)
508
+ provider_instance.send(:build_env, {provider_runtime: runtime})
509
+ rescue ArgumentError, NoMethodError
510
+ {}
511
+ end
512
+
456
513
  def monotonic_now
457
514
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
458
515
  end
@@ -775,6 +775,15 @@ module AgentHarness
775
775
  raise NotImplementedError, "#{self.class} must implement #send_message"
776
776
  end
777
777
 
778
+ # Return the provider CLI execution plan without executing the command.
779
+ #
780
+ # @param prompt [String] the prompt to send
781
+ # @param options [Hash] provider-specific options
782
+ # @return [Hash] with :command, :env, and :preparation keys
783
+ def plan_execution(prompt:, **options)
784
+ raise NotImplementedError, "#{self.class} must implement #plan_execution"
785
+ end
786
+
778
787
  # Provider configuration schema for app-driven setup UIs
779
788
  #
780
789
  # Returns metadata describing the configurable fields, supported
@@ -1042,6 +1051,15 @@ module AgentHarness
1042
1051
  {healthy: true, message: "OK"}
1043
1052
  end
1044
1053
 
1054
+ # Lightweight provider-owned preflight check executed before smoke tests.
1055
+ #
1056
+ # @param env [Hash] request-scoped environment overrides
1057
+ # @param timeout [Numeric] time budget in seconds
1058
+ # @return [Hash] with :healthy and optional :reason keys
1059
+ def preflight_check(env:, timeout: 10)
1060
+ {healthy: true}
1061
+ end
1062
+
1045
1063
  # Canonical smoke-test contract for this provider instance.
1046
1064
  #
1047
1065
  # @return [Hash, nil] smoke-test metadata
@@ -263,6 +263,26 @@ module AgentHarness
263
263
  cleanup_llm_history_file!(llm_history_path)
264
264
  end
265
265
 
266
+ def plan_execution(prompt:, **options)
267
+ log_debug("plan_execution_start", prompt_length: prompt.length, options: options.keys)
268
+
269
+ options = normalize_provider_runtime(options)
270
+ options = normalize_mcp_servers(options)
271
+ validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
272
+
273
+ llm_history_path = generate_llm_history_path
274
+
275
+ {
276
+ command: build_command(prompt, options.merge(llm_history_path: llm_history_path)),
277
+ env: build_env(options),
278
+ preparation: build_execution_preparation(options)
279
+ }
280
+ rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
281
+ raise
282
+ rescue => e
283
+ handle_error(e, prompt: prompt, options: options)
284
+ end
285
+
266
286
  # Parse raw container output into a Response.
267
287
  #
268
288
  # Overrides the base implementation to support the
@@ -386,6 +386,17 @@ module AgentHarness
386
386
  cleanup_mcp_tempfiles!
387
387
  end
388
388
 
389
+ def plan_execution(prompt:, **options)
390
+ if options[:mode] == :text
391
+ raise ProviderError,
392
+ "Anthropic text mode uses the HTTP transport and does not produce a CLI execution plan"
393
+ end
394
+
395
+ super
396
+ ensure
397
+ cleanup_mcp_tempfiles!
398
+ end
399
+
389
400
  def api_key_env_var_names = ["ANTHROPIC_API_KEY"]
390
401
 
391
402
  def api_key_unset_vars = ["ANTHROPIC_BASE_URL", "ANTHROPIC_HEADER_X_AGENT_RUN_ID", "ANTHROPIC_HEADER_X_PROXY_TOKEN"]
@@ -197,6 +197,56 @@ module AgentHarness
197
197
  handle_error(e, prompt: prompt, options: options)
198
198
  end
199
199
 
200
+ # Return the provider CLI execution plan without executing it.
201
+ #
202
+ # @param prompt [String] the prompt to send
203
+ # @param options [Hash] additional options
204
+ # @return [Hash] with :command, :env, and :preparation keys
205
+ def plan_execution(prompt:, **options)
206
+ log_debug("plan_execution_start", prompt_length: prompt.length, options: options.keys)
207
+
208
+ if options[:mode] == :text && !supports_text_mode?
209
+ log_debug("text_mode_cli_fallback", provider: self.class.provider_name)
210
+ options = options.except(:mode).merge(tools: :none)
211
+ end
212
+
213
+ if options[:tools] && !supports_tool_control?
214
+ log_debug("tools_option_unsupported",
215
+ provider: self.class.provider_name,
216
+ tools: options[:tools])
217
+ @logger&.warn(
218
+ "[AgentHarness::#{self.class.provider_name}] tools option is not supported " \
219
+ "by this provider and will be ignored"
220
+ )
221
+ end
222
+
223
+ options = normalize_provider_runtime(options)
224
+
225
+ extension_context = apply_extensions_to_prompt(prompt, options)
226
+ prompt = extension_context.prompt
227
+ options = extension_context.options
228
+ options = normalize_sub_agent(options)
229
+ prompt = apply_sub_agent_to_prompt(prompt, options[:translated_sub_agent])
230
+
231
+ options = normalize_mcp_servers(options)
232
+ validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
233
+
234
+ {
235
+ command: build_command(prompt, options),
236
+ env: build_env(options),
237
+ preparation: build_execution_preparation(options)
238
+ }
239
+ rescue ExtensionCompatibilityError, McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
240
+ raise
241
+ rescue => e
242
+ handle_error(e, prompt: prompt, options: options)
243
+ ensure
244
+ # build_command may call build_mcp_flags which creates tempfiles (and
245
+ # in Docker even invokes the executor) via write_mcp_config_file.
246
+ # Clean up so that planning has no lasting side effects.
247
+ cleanup_mcp_tempfiles! if respond_to?(:cleanup_mcp_tempfiles!, true)
248
+ end
249
+
200
250
  # Send a multi-turn chat message via the provider's chat transport.
201
251
  #
202
252
  # Providers that support chat mode can accept either +conversation:+
@@ -368,6 +418,19 @@ module AgentHarness
368
418
  nil
369
419
  end
370
420
 
421
+ # Run a lightweight provider-owned preflight check before committing to a
422
+ # full prompt execution.
423
+ #
424
+ # Providers can override this to validate request-scoped connectivity,
425
+ # credentials, CLI version, or other fast-fail prerequisites.
426
+ #
427
+ # @param env [Hash] request-scoped environment overrides
428
+ # @param timeout [Numeric] time budget in seconds
429
+ # @return [Hash] with :healthy and optional :reason keys
430
+ def preflight_check(env:, timeout: 10)
431
+ {healthy: true}
432
+ end
433
+
371
434
  protected
372
435
 
373
436
  # Build CLI command - override in subclasses
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "net/http"
5
+ require "uri"
4
6
 
5
7
  module AgentHarness
6
8
  module Providers
@@ -537,30 +539,7 @@ module AgentHarness
537
539
  end
538
540
 
539
541
  def auth_status
540
- api_key = ENV["OPENAI_API_KEY"]
541
- if api_key && !api_key.strip.empty?
542
- if api_key.strip.start_with?("sk-")
543
- return {valid: true, expires_at: nil, error: nil, auth_method: :api_key}
544
- else
545
- return {valid: false, expires_at: nil, error: "OPENAI_API_KEY is set but does not appear to be a valid OpenAI API key", auth_method: nil}
546
- end
547
- end
548
-
549
- credentials = read_codex_credentials
550
- if credentials
551
- key = credentials["api_key"] || credentials["apiKey"] || credentials["OPENAI_API_KEY"]
552
- if key.is_a?(String) && !key.strip.empty?
553
- if key.strip.start_with?("sk-")
554
- return {valid: true, expires_at: nil, error: nil, auth_method: :config_file}
555
- else
556
- return {valid: false, expires_at: nil, error: "Config file API key is set but does not appear to be a valid OpenAI API key", auth_method: nil}
557
- end
558
- end
559
- end
560
-
561
- {valid: false, expires_at: nil, error: "No OpenAI API key found. Set OPENAI_API_KEY or configure in #{codex_config_path}", auth_method: nil}
562
- rescue IOError, JSON::ParserError => e
563
- {valid: false, expires_at: nil, error: e.message, auth_method: nil}
542
+ auth_status_for_env({})
564
543
  end
565
544
 
566
545
  def health_status
@@ -576,6 +555,32 @@ module AgentHarness
576
555
  {healthy: true, message: "Codex CLI available and authenticated"}
577
556
  end
578
557
 
558
+ def preflight_check(env:, timeout: 10)
559
+ auth = auth_status_for_env(env)
560
+ return {healthy: false, reason: auth[:error], error_category: :authentication} unless auth[:valid]
561
+
562
+ version = codex_cli_version(env: env, timeout: timeout)
563
+ unless version
564
+ return {
565
+ healthy: false,
566
+ reason: "Codex CLI version check failed. Ensure 'codex' is installed and available in PATH.",
567
+ error_category: :installation
568
+ }
569
+ end
570
+
571
+ unless SUPPORTED_CLI_REQUIREMENT.satisfied_by?(version)
572
+ return {
573
+ healthy: false,
574
+ reason: "Unsupported Codex CLI version #{version}. Expected #{SUPPORTED_CLI_REQUIREMENT}.",
575
+ error_category: :installation
576
+ }
577
+ end
578
+
579
+ check_base_url_reachability(env: env, timeout: timeout)
580
+ rescue => e
581
+ {healthy: false, reason: "Codex preflight failed: #{e.message}"}
582
+ end
583
+
579
584
  def validate_config
580
585
  errors = []
581
586
 
@@ -734,6 +739,150 @@ module AgentHarness
734
739
 
735
740
  private
736
741
 
742
+ def auth_status_for_env(env)
743
+ api_key = env_fetch(env, "OPENAI_API_KEY")
744
+ # Fall back to process ENV when the provided env hash does not override auth keys
745
+ if api_key.nil? && !env.key?("OPENAI_API_KEY") && !env.key?(:OPENAI_API_KEY)
746
+ api_key = ENV["OPENAI_API_KEY"]
747
+ end
748
+
749
+ if api_key.nil? || api_key.strip.empty?
750
+ credentials = read_codex_credentials_for_env(env)
751
+ if credentials
752
+ key = credentials["api_key"] || credentials["apiKey"] || credentials["OPENAI_API_KEY"]
753
+ if key.is_a?(String) && !key.strip.empty?
754
+ if key.strip.start_with?("sk-")
755
+ return {valid: true, expires_at: nil, error: nil, auth_method: :config_file}
756
+ end
757
+
758
+ return {
759
+ valid: false,
760
+ expires_at: nil,
761
+ error: "Config file API key is set but does not appear to be a valid OpenAI API key",
762
+ auth_method: nil
763
+ }
764
+ end
765
+ end
766
+
767
+ return {
768
+ valid: false,
769
+ expires_at: nil,
770
+ error: "No OpenAI API key found. Set OPENAI_API_KEY or configure in #{codex_config_path_for_env(env)}",
771
+ auth_method: nil
772
+ }
773
+ end
774
+
775
+ if api_key.strip.start_with?("sk-")
776
+ {valid: true, expires_at: nil, error: nil, auth_method: :api_key}
777
+ else
778
+ {
779
+ valid: false,
780
+ expires_at: nil,
781
+ error: "OPENAI_API_KEY is set but does not appear to be a valid OpenAI API key",
782
+ auth_method: nil
783
+ }
784
+ end
785
+ rescue IOError, JSON::ParserError => e
786
+ {valid: false, expires_at: nil, error: e.message, auth_method: nil}
787
+ end
788
+
789
+ def codex_cli_version(env:, timeout:)
790
+ result = @executor.execute([self.class.binary_name, "--version"], timeout: timeout, env: env)
791
+ version_string = [result.stdout, result.stderr].join("\n")[/(\d+\.\d+\.\d+)/, 1]
792
+ return nil unless version_string
793
+
794
+ Gem::Version.new(version_string)
795
+ rescue # rubocop prefers bare rescue; in Ruby this catches StandardError, not Exception/SignalException
796
+ nil
797
+ end
798
+
799
+ def check_base_url_reachability(env:, timeout:)
800
+ uri = codex_base_url_uri(env)
801
+ http = Net::HTTP.new(uri.host, uri.port)
802
+ http.use_ssl = uri.scheme == "https"
803
+ http.open_timeout = timeout
804
+ http.read_timeout = timeout
805
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
806
+
807
+ response = http.start do |client|
808
+ head_response = client.request(Net::HTTP::Head.new(uri))
809
+
810
+ if http_success_or_redirect?(head_response) || http_auth_rejection?(head_response)
811
+ head_response
812
+ else
813
+ client.request(Net::HTTP::Get.new(uri))
814
+ end
815
+ end
816
+
817
+ return {healthy: true} if http_success_or_redirect?(response)
818
+
819
+ response_code = response.code.to_i
820
+ # 401/403 confirm the endpoint exists and is reachable; auth is
821
+ # validated separately by auth_status_for_env.
822
+ return {healthy: true} if http_auth_rejection?(response)
823
+ if invalid_base_url_response_code?(response_code)
824
+ return {
825
+ healthy: false,
826
+ reason: "Codex API base URL #{uri} returned HTTP #{response.code}. Check OPENAI_BASE_URL; the configured URL appears to point at an invalid API path.",
827
+ error_category: :configuration
828
+ }
829
+ end
830
+
831
+ {
832
+ healthy: false,
833
+ reason: "Codex API base URL #{uri} returned HTTP #{response.code}. Check OPENAI_BASE_URL, proxy configuration, and network policy.",
834
+ error_category: (response_code >= 500) ? :transient : :configuration
835
+ }
836
+ rescue URI::InvalidURIError => e
837
+ {
838
+ healthy: false,
839
+ reason: e.message.start_with?("OPENAI_BASE_URL") ? e.message : "OPENAI_BASE_URL is invalid. Check the configured URL format.",
840
+ error_category: :configuration
841
+ }
842
+ rescue SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError => e
843
+ {
844
+ healthy: false,
845
+ reason: "Codex API base URL #{env_fetch(env, "OPENAI_BASE_URL") || "https://api.openai.com"} is unreachable: #{e.message}. Check DNS, proxy settings, and network policy.",
846
+ error_category: :transient
847
+ }
848
+ end
849
+
850
+ def codex_base_url_uri(env)
851
+ raw_url = env_fetch(env, "OPENAI_BASE_URL")
852
+ # Only fall back to the default URL; do not read process ENV here, as the
853
+ # caller may have intentionally omitted OPENAI_BASE_URL to use the default.
854
+ raw_url = "https://api.openai.com" if raw_url.nil? || raw_url.empty?
855
+
856
+ uri = URI.parse(raw_url)
857
+
858
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
859
+ raise URI::InvalidURIError,
860
+ "OPENAI_BASE_URL must be an absolute HTTP or HTTPS URL (got #{raw_url.inspect})"
861
+ end
862
+
863
+ uri.path = "/" if uri.path.nil? || uri.path.empty?
864
+ uri
865
+ end
866
+
867
+ def env_fetch(env, key)
868
+ return env[key] if env.key?(key)
869
+ return env[key.to_sym] if env.key?(key.to_sym)
870
+
871
+ nil
872
+ end
873
+
874
+ def http_success_or_redirect?(response)
875
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
876
+ end
877
+
878
+ def http_auth_rejection?(response)
879
+ [401, 403].include?(response.code.to_i)
880
+ end
881
+
882
+ def invalid_base_url_response_code?(response_code)
883
+ [404, 410].include?(response_code)
884
+ end
885
+
737
886
  def build_streaming_event(event)
738
887
  raw_event, payload, dispatch_type = unwrap_streaming_event(event)
739
888
  return unless payload.is_a?(Hash)
@@ -1644,7 +1793,11 @@ module AgentHarness
1644
1793
  end
1645
1794
 
1646
1795
  def read_codex_credentials
1647
- path = codex_config_path
1796
+ read_codex_credentials_for_env({})
1797
+ end
1798
+
1799
+ def read_codex_credentials_for_env(env)
1800
+ path = codex_config_path_for_env(env)
1648
1801
  return nil unless File.exist?(path)
1649
1802
 
1650
1803
  parsed = JSON.parse(File.read(path))
@@ -1660,7 +1813,13 @@ module AgentHarness
1660
1813
  end
1661
1814
 
1662
1815
  def codex_config_path
1663
- config_dir = ENV["CODEX_CONFIG_DIR"] || File.expand_path("~/.codex")
1816
+ codex_config_path_for_env({})
1817
+ end
1818
+
1819
+ def codex_config_path_for_env(env)
1820
+ config_dir = env_fetch(env, "CODEX_CONFIG_DIR")
1821
+ config_dir = ENV["CODEX_CONFIG_DIR"] if config_dir.nil? || config_dir.empty?
1822
+ config_dir = File.expand_path("~/.codex") if config_dir.nil? || config_dir.empty?
1664
1823
  File.join(config_dir, "config.json")
1665
1824
  end
1666
1825
 
@@ -312,6 +312,28 @@ module AgentHarness
312
312
  handle_error(e, prompt: prompt, options: options)
313
313
  end
314
314
 
315
+ def plan_execution(prompt:, **options)
316
+ log_debug("plan_execution_start", prompt_length: prompt.length, options: options.keys)
317
+
318
+ options = normalize_provider_runtime(options)
319
+ options = normalize_mcp_servers(options)
320
+ validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
321
+
322
+ runtime = options[:provider_runtime]
323
+ cmd = [self.class.binary_name, "-p"]
324
+ cmd.concat(runtime.flags) if runtime&.flags&.any?
325
+
326
+ {
327
+ command: cmd,
328
+ env: build_env(options),
329
+ preparation: build_execution_preparation(options)
330
+ }
331
+ rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
332
+ raise
333
+ rescue => e
334
+ handle_error(e, prompt: prompt, options: options)
335
+ end
336
+
315
337
  protected
316
338
 
317
339
  def build_command(prompt, options)
@@ -188,11 +188,11 @@ module AgentHarness
188
188
  ["--allow-all"]
189
189
  end
190
190
 
191
- def supports_sessions?(probe_timeout: nil, env: {}, version: nil)
191
+ def supports_sessions?(probe_timeout: nil, env: {}, version: :not_provided)
192
192
  legacy_prompt_cli?(version: version, probe_timeout: probe_timeout, env: env)
193
193
  end
194
194
 
195
- def session_flags(session_id, version: nil, probe_timeout: nil, env: {})
195
+ def session_flags(session_id, version: :not_provided, probe_timeout: nil, env: {})
196
196
  return [] unless session_id && !session_id.empty?
197
197
  return [] unless legacy_prompt_cli?(version: version, probe_timeout: probe_timeout, env: env)
198
198
 
@@ -345,6 +345,30 @@ module AgentHarness
345
345
  handle_error(e, prompt: prompt, options: options)
346
346
  end
347
347
 
348
+ def plan_execution(prompt:, **options)
349
+ log_debug("plan_execution_start", prompt_length: prompt.length, options: options.keys)
350
+
351
+ options = normalize_provider_runtime(options)
352
+ options = normalize_mcp_servers(options)
353
+ validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
354
+
355
+ env = build_env(options)
356
+ version = planned_copilot_cli_version(env)
357
+ raise unsupported_subcommand_cli_error if subcommand_cli_version?(version)
358
+
359
+ options = options.merge(_command_env: env, _planned_cli_version: version)
360
+
361
+ {
362
+ command: build_command(prompt, options),
363
+ env: env,
364
+ preparation: build_execution_preparation(options)
365
+ }
366
+ rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
367
+ raise
368
+ rescue => e
369
+ handle_error(e, prompt: prompt, options: options)
370
+ end
371
+
348
372
  # Parse raw container output into a Response.
349
373
  #
350
374
  # Overrides the base implementation to support the
@@ -377,7 +401,14 @@ module AgentHarness
377
401
  def build_command(prompt, options)
378
402
  env = options.fetch(:_command_env) { build_env(options) }
379
403
  runtime = options[:provider_runtime]
380
- version = copilot_cli_version(probe_timeout: options[:_version_probe_timeout], env: env)
404
+ version = if options.key?(:_planned_cli_version)
405
+ options[:_planned_cli_version]
406
+ else
407
+ copilot_cli_version(
408
+ probe_timeout: options[:_version_probe_timeout],
409
+ env: env
410
+ )
411
+ end
381
412
 
382
413
  raise unsupported_subcommand_cli_error if subcommand_cli_version?(version)
383
414
 
@@ -440,13 +471,13 @@ module AgentHarness
440
471
  ["--allow-all-tools"]
441
472
  end
442
473
 
443
- def supports_json_output_format?(probe_timeout: nil, env: {}, version: nil)
444
- version ||= copilot_cli_version(probe_timeout: probe_timeout, env: env)
474
+ def supports_json_output_format?(probe_timeout: nil, env: {}, version: :not_provided)
475
+ version = copilot_cli_version(probe_timeout: probe_timeout, env: env) if version == :not_provided
445
476
  !version.nil? && !subcommand_cli_version?(version) && version >= JSON_OUTPUT_MIN_VERSION
446
477
  end
447
478
 
448
- def legacy_prompt_cli?(probe_timeout: nil, env: {}, version: nil)
449
- version ||= copilot_cli_version(probe_timeout: probe_timeout, env: env)
479
+ def legacy_prompt_cli?(probe_timeout: nil, env: {}, version: :not_provided)
480
+ version = copilot_cli_version(probe_timeout: probe_timeout, env: env) if version == :not_provided
450
481
  !version.nil? && !subcommand_cli_version?(version)
451
482
  end
452
483
 
@@ -475,6 +506,17 @@ module AgentHarness
475
506
  @copilot_cli_versions[cache_key] = nil if defined?(cache_key)
476
507
  end
477
508
 
509
+ def planned_copilot_cli_version(env)
510
+ cache_key = version_probe_cache_key(env)
511
+ @copilot_cli_versions ||= {}
512
+ return @copilot_cli_versions[cache_key] if @copilot_cli_versions.key?(cache_key)
513
+
514
+ # When no cached version is available (cold start), return nil so
515
+ # build_command falls back to the conservative -s flag path, matching
516
+ # the behavior of send_message when the version probe returns nil.
517
+ nil
518
+ end
519
+
478
520
  def version_probe_cache_key(env)
479
521
  [
480
522
  probe_env_cache_component(env, "PATH", inherited_label: :inherited_path, override_label: :path_override),
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.14.1"
4
+ VERSION = "0.16.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.14.1
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan