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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/lib/agent_harness/provider_health_check.rb +60 -3
- data/lib/agent_harness/providers/adapter.rb +18 -0
- data/lib/agent_harness/providers/aider.rb +20 -0
- data/lib/agent_harness/providers/anthropic.rb +11 -0
- data/lib/agent_harness/providers/base.rb +63 -0
- data/lib/agent_harness/providers/codex.rb +185 -26
- data/lib/agent_harness/providers/cursor.rb +22 -0
- data/lib/agent_harness/providers/github_copilot.rb +49 -7
- data/lib/agent_harness/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 12fc4554399bc5663e5fcde0747f0ba4f25b92499633fb9e6916f5f205f9f05e
|
|
4
|
+
data.tar.gz: 2c5f3e1235a2c648c246988d684f9479a2151cbd6fb905e8774581115ce472ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e0f815d6b6fb68e0d4b1307d0a4d594022cd814a213afbea4a87db1759c8a00513ec43b95a144698f5a3dca05e91e71474ee239865ad9b8174033c0694e7e838
|
|
7
|
+
data.tar.gz: dfa7e99ee7c88cc4fce145dcbc431419d61a86dac4f9ebf5e3b6045c92729bb640b7dbbab5d1b597439039f0381d6f12f05de59731e20f282e8245c8dc15e052
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
444
|
-
version
|
|
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:
|
|
449
|
-
version
|
|
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),
|