agent-harness 0.5.6 → 0.5.8

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.
@@ -17,10 +17,32 @@ module AgentHarness
17
17
  class Registry
18
18
  include Singleton
19
19
 
20
+ BUILTIN_PROVIDER_DEFINITIONS = [
21
+ {name: :claude, require_path: "agent_harness/providers/anthropic", class_name: :Anthropic, aliases: [:anthropic]},
22
+ {name: :cursor, require_path: "agent_harness/providers/cursor", class_name: :Cursor, aliases: []},
23
+ {name: :gemini, require_path: "agent_harness/providers/gemini", class_name: :Gemini, aliases: []},
24
+ {
25
+ name: :github_copilot,
26
+ require_path: "agent_harness/providers/github_copilot",
27
+ class_name: :GithubCopilot,
28
+ aliases: [:copilot]
29
+ },
30
+ {name: :codex, require_path: "agent_harness/providers/codex", class_name: :Codex, aliases: []},
31
+ {name: :opencode, require_path: "agent_harness/providers/opencode", class_name: :Opencode, aliases: []},
32
+ {name: :kilocode, require_path: "agent_harness/providers/kilocode", class_name: :Kilocode, aliases: []},
33
+ {name: :aider, require_path: "agent_harness/providers/aider", class_name: :Aider, aliases: []},
34
+ {name: :mistral_vibe, require_path: "agent_harness/providers/mistral_vibe", class_name: :MistralVibe, aliases: []}
35
+ ].freeze
36
+
20
37
  def initialize
21
38
  @providers = {}
22
39
  @aliases = {}
40
+ @provider_aliases = Hash.new { |hash, key| hash[key] = [] }
41
+ @metadata_runtime_available = {}
42
+ @provider_metadata_cache = {}
43
+ @provider_metadata_catalog_cache = nil
23
44
  @builtin_registered = false
45
+ @builtin_registration_in_progress = false
24
46
  end
25
47
 
26
48
  # Register a provider class
@@ -32,11 +54,32 @@ module AgentHarness
32
54
  def register(name, klass, aliases: [])
33
55
  name = name.to_sym
34
56
  validate_provider_class!(klass)
57
+ normalized_aliases = aliases
58
+ .filter_map do |alias_name|
59
+ normalized_alias = alias_name.to_s.strip
60
+ next if normalized_alias.empty?
61
+
62
+ normalized_alias.to_sym
63
+ end
64
+ .uniq - [name]
65
+
66
+ validate_provider_name!(name)
67
+ validate_aliases!(name, normalized_aliases)
68
+ unregister_aliases_for(name)
35
69
 
36
70
  @providers[name] = klass
71
+ @provider_aliases[name] = normalized_aliases
72
+ @metadata_runtime_available.delete(name)
73
+ clear_registry_metadata_cache!
74
+ clear_metadata_caches!(klass)
75
+
76
+ normalized_aliases.each do |alias_name|
77
+ previous_owner = @aliases[alias_name]
78
+ if previous_owner && previous_owner != name
79
+ @provider_aliases[previous_owner] = @provider_aliases[previous_owner] - [alias_name]
80
+ end
37
81
 
38
- aliases.each do |alias_name|
39
- @aliases[alias_name.to_sym] = name
82
+ @aliases[alias_name] = name
40
83
  end
41
84
 
42
85
  AgentHarness.logger&.debug("[AgentHarness::Registry] Registered provider: #{name}")
@@ -63,6 +106,15 @@ module AgentHarness
63
106
  @providers.key?(name)
64
107
  end
65
108
 
109
+ # Resolve a provider lookup key to its canonical registered name.
110
+ #
111
+ # @param name [Symbol, String] the provider name or alias
112
+ # @return [Symbol] canonical provider name
113
+ def canonical_name(name)
114
+ ensure_builtin_providers_registered
115
+ resolve_alias(name.to_sym)
116
+ end
117
+
66
118
  # List all registered provider names
67
119
  #
68
120
  # @return [Array<Symbol>] provider names
@@ -79,30 +131,390 @@ module AgentHarness
79
131
  @providers.select { |_, klass| klass.available? }.keys
80
132
  end
81
133
 
82
- # Reset registry (useful for testing)
134
+ # Fetch install contract metadata for a provider.
83
135
  #
84
- # @return [void]
136
+ # @param name [Symbol, String] the provider name
137
+ # @return [Hash] the provider install contract
138
+ # @raise [ConfigurationError] if the provider does not expose an
139
+ # install contract
140
+ def install_contract(name)
141
+ provider_class = get(name)
142
+
143
+ unless provider_class.respond_to?(:install_contract)
144
+ raise ConfigurationError, "Provider #{provider_class} does not implement .install_contract"
145
+ end
146
+
147
+ contract = provider_class.install_contract
148
+ unless contract
149
+ raise ConfigurationError, "Provider #{provider_class} does not expose an install contract"
150
+ end
151
+
152
+ contract
153
+ end
154
+
155
+ # Fetch installation metadata for a provider.
156
+ #
157
+ # @param name [Symbol, String] the provider name
158
+ # @param options [Hash] optional target selection (for example, `version:`)
159
+ # @return [Hash, nil] provider installation contract, or nil when the
160
+ # registered provider class does not define `.installation_contract`
161
+ # @raise [ConfigurationError] if provider not found
162
+ def installation_contract(name, **options)
163
+ provider_class = get(name)
164
+ return nil unless provider_class.respond_to?(:installation_contract)
165
+
166
+ provider_class.installation_contract(**options)
167
+ end
168
+
169
+ # Get installation metadata for all providers that expose it.
170
+ #
171
+ # @return [Hash<Symbol, Hash>] installation contracts keyed by provider
172
+ def installation_contracts
173
+ ensure_builtin_providers_registered
174
+
175
+ @providers.each_with_object({}) do |(name, klass), contracts|
176
+ next unless klass.respond_to?(:installation_contract)
177
+
178
+ contract = klass.installation_contract
179
+ contracts[name] = contract if contract
180
+ end
181
+ end
182
+
183
+ # Get smoke-test metadata for a provider.
184
+ #
185
+ # @param name [Symbol, String] the provider name
186
+ # @return [Hash, nil] smoke-test contract
187
+ # @raise [ConfigurationError] if the provider name is not registered
188
+ def smoke_test_contract(name)
189
+ klass = get(name)
190
+ return nil unless klass.respond_to?(:smoke_test_contract)
191
+
192
+ klass.smoke_test_contract
193
+ end
194
+
195
+ # Get smoke-test metadata for all providers that expose it.
196
+ #
197
+ # @return [Hash<Symbol, Hash>] smoke-test contracts keyed by provider
198
+ def smoke_test_contracts
199
+ ensure_builtin_providers_registered
200
+
201
+ @providers.each_with_object({}) do |(name, klass), contracts|
202
+ next unless klass.respond_to?(:smoke_test_contract)
203
+
204
+ contract = klass.smoke_test_contract
205
+ contracts[name] = contract if contract
206
+ end
207
+ end
208
+
209
+ # Fetch consolidated provider metadata for a provider.
210
+ #
211
+ # @param name [Symbol, String] the provider name or alias
212
+ # @return [Hash] provider metadata
213
+ # @raise [ConfigurationError] if provider not found
214
+ def provider_metadata(name, refresh: false)
215
+ ensure_builtin_providers_registered
216
+
217
+ requested_name = name.to_sym
218
+ canonical_name = resolve_alias(requested_name)
219
+ cache_key = [requested_name, canonical_name]
220
+
221
+ return duplicate_metadata(@provider_metadata_cache[cache_key]) if !refresh && @provider_metadata_cache.key?(cache_key)
222
+
223
+ refresh_provider_metadata_cache!(requested_name, canonical_name, refresh: refresh)
224
+ end
225
+
226
+ # Get consolidated metadata for all registered providers.
227
+ #
228
+ # @param refresh [Boolean] when true, refresh live runtime metadata such
229
+ # as CLI availability instead of reusing cached values
230
+ # @return [Hash<Symbol, Hash>] provider metadata keyed by canonical provider
231
+ def provider_metadata_catalog(refresh: false)
232
+ ensure_builtin_providers_registered
233
+
234
+ return duplicate_metadata(@provider_metadata_catalog_cache) if !refresh && @provider_metadata_catalog_cache
235
+
236
+ if refresh
237
+ clear_registry_metadata_cache!
238
+ clear_all_auth_status_metadata_caches!
239
+ end
240
+
241
+ catalog = @providers.keys.each_with_object({}) do |name, result|
242
+ result[name] = refresh_provider_metadata_cache!(
243
+ name,
244
+ name,
245
+ refresh: refresh,
246
+ invalidate_provider_cache: false,
247
+ invalidate_catalog: false
248
+ )
249
+ end
250
+
251
+ @provider_metadata_catalog_cache = duplicate_metadata(catalog)
252
+ duplicate_metadata(catalog)
253
+ end
254
+
85
255
  def reset!
256
+ @providers.each_value { |klass| clear_metadata_caches!(klass) }
86
257
  @providers.clear
87
258
  @aliases.clear
259
+ @provider_aliases.clear
260
+ @metadata_runtime_available.clear
261
+ clear_registry_metadata_cache!
88
262
  @builtin_registered = false
263
+ @builtin_registration_in_progress = false
89
264
  end
90
265
 
91
266
  private
92
267
 
268
+ def clear_registry_metadata_cache!
269
+ @provider_metadata_cache.clear
270
+ @provider_metadata_catalog_cache = nil
271
+ end
272
+
273
+ def invalidate_provider_metadata_cache!(canonical_name)
274
+ @provider_metadata_cache.delete_if do |(_, cached_canonical_name), _|
275
+ cached_canonical_name == canonical_name
276
+ end
277
+ end
278
+
279
+ def duplicate_metadata(value)
280
+ case value
281
+ when Hash
282
+ value.each_with_object({}) do |(key, nested_value), copy|
283
+ copy[key] = duplicate_metadata(nested_value)
284
+ end
285
+ when Array
286
+ value.map { |nested_value| duplicate_metadata(nested_value) }
287
+ when String
288
+ value.dup
289
+ else
290
+ value
291
+ end
292
+ end
293
+
93
294
  def resolve_alias(name)
94
295
  @aliases[name] || name
95
296
  end
96
297
 
298
+ def unregister_aliases_for(name)
299
+ previous_aliases = @provider_aliases[name]
300
+ return if previous_aliases.nil? || previous_aliases.empty?
301
+
302
+ previous_aliases.each do |alias_name|
303
+ @aliases.delete(alias_name)
304
+ end
305
+ end
306
+
307
+ def clear_metadata_caches!(klass)
308
+ clear_class_metadata_cache!(klass, :@metadata_runtime_available)
309
+ clear_class_metadata_cache!(klass, :@auth_status_available)
310
+ end
311
+
312
+ def clear_all_auth_status_metadata_caches!
313
+ @providers.each_value { |klass| clear_class_auth_status_metadata_cache!(klass) }
314
+ end
315
+
316
+ def clear_class_auth_status_metadata_cache!(klass, canonical_name = nil)
317
+ return unless klass.instance_variable_defined?(:@auth_status_available)
318
+
319
+ return clear_class_metadata_cache!(klass, :@auth_status_available) unless canonical_name
320
+
321
+ auth_status_cache = klass.instance_variable_get(:@auth_status_available)
322
+ auth_status_cache.delete_if do |(_requested_name, cached_canonical_name), _|
323
+ cached_canonical_name == canonical_name
324
+ end
325
+ end
326
+
327
+ def build_provider_metadata(requested_name, canonical_name, refresh:)
328
+ klass = @providers[canonical_name] || raise(ConfigurationError, "Unknown provider: #{canonical_name}")
329
+ aliases = @provider_aliases[canonical_name]
330
+
331
+ if klass.respond_to?(:provider_metadata)
332
+ klass.provider_metadata(
333
+ aliases: aliases,
334
+ refresh: refresh,
335
+ requested_name: requested_name,
336
+ canonical_name: canonical_name
337
+ )
338
+ else
339
+ fallback_provider_metadata(canonical_name, klass, aliases, refresh: refresh)
340
+ end
341
+ end
342
+
343
+ def refresh_provider_metadata_cache!(
344
+ requested_name,
345
+ canonical_name,
346
+ refresh:,
347
+ invalidate_provider_cache: refresh,
348
+ invalidate_catalog: true
349
+ )
350
+ cache_key = [requested_name, canonical_name]
351
+ invalidate_provider_metadata_cache!(canonical_name) if invalidate_provider_cache
352
+ klass = @providers[canonical_name]
353
+ clear_class_auth_status_metadata_cache!(klass, canonical_name) if refresh && klass
354
+ @provider_metadata_catalog_cache = nil if refresh && invalidate_catalog
355
+
356
+ metadata = build_provider_metadata(requested_name, canonical_name, refresh: refresh)
357
+ @provider_metadata_cache[cache_key] = duplicate_metadata(metadata)
358
+ duplicate_metadata(metadata)
359
+ end
360
+
361
+ def clear_class_metadata_cache!(klass, ivar_name)
362
+ return unless klass.instance_variable_defined?(ivar_name)
363
+
364
+ klass.remove_instance_variable(ivar_name)
365
+ end
366
+
97
367
  def validate_provider_class!(klass)
98
368
  includes_adapter = klass.include?(Adapter)
99
369
  has_required_methods = klass.respond_to?(:provider_name) &&
100
370
  klass.respond_to?(:available?) &&
101
371
  klass.respond_to?(:binary_name)
102
372
 
103
- return if includes_adapter || has_required_methods
373
+ return if includes_adapter
374
+ return if has_required_methods
375
+
376
+ raise ConfigurationError,
377
+ "Provider class must include AgentHarness::Providers::Adapter or implement required class methods"
378
+ end
379
+
380
+ def validate_provider_name!(name)
381
+ # Reject canonical provider names that match a builtin canonical name
382
+ # (e.g. :claude, :gemini). Authentication hardcodes routing for names
383
+ # like :claude/:anthropic to provider-specific OAuth file handling, so
384
+ # registering a non-builtin provider under those names yields incorrect
385
+ # auth behavior at runtime.
386
+ if builtin_provider_name?(name) && !@builtin_registration_in_progress
387
+ raise ConfigurationError,
388
+ "Provider name #{name.inspect} is reserved as a builtin canonical provider"
389
+ end
390
+
391
+ # Reject canonical provider names that match a reserved builtin alias
392
+ # (e.g. :anthropic is an alias for :claude).
393
+ builtin_alias_owner = reserved_builtin_alias_owner(name)
394
+ if builtin_alias_owner && !@builtin_registration_in_progress
395
+ raise ConfigurationError,
396
+ "Provider name #{name.inspect} is reserved as a builtin alias for #{builtin_alias_owner.inspect}"
397
+ end
398
+
399
+ conflicting_provider = @aliases[name]
400
+ return unless conflicting_provider && conflicting_provider != name
401
+
402
+ raise ConfigurationError, "Provider #{name.inspect} conflicts with registered alias for #{conflicting_provider.inspect}"
403
+ end
404
+
405
+ def validate_aliases!(name, aliases)
406
+ conflicting_alias = aliases.find do |alias_name|
407
+ next false if alias_name == name
408
+ next true if builtin_provider_name?(alias_name)
409
+
410
+ # Reject aliases that match a reserved builtin alias (e.g. :anthropic
411
+ # for :claude) unless the registering provider is the owning builtin.
412
+ # Authentication hardcodes routing for these names, so allowing a
413
+ # custom provider to claim them would cause auth_status/auth_url to
414
+ # hit the wrong provider.
415
+ alias_owner = reserved_builtin_alias_owner(alias_name)
416
+ next true if alias_owner && alias_owner != name
417
+
418
+ @providers.key?(alias_name) ||
419
+ (@aliases.key?(alias_name) && @aliases[alias_name] != name)
420
+ end
421
+ return unless conflicting_alias
422
+
423
+ builtin_alias_owner = reserved_builtin_alias_owner(conflicting_alias)
424
+ owner = if builtin_alias_owner
425
+ builtin_alias_owner
426
+ elsif @providers.key?(conflicting_alias)
427
+ conflicting_alias
428
+ elsif builtin_provider_name?(conflicting_alias)
429
+ :builtin_provider
430
+ else
431
+ @aliases[conflicting_alias]
432
+ end
433
+ raise ConfigurationError, "Alias #{conflicting_alias.inspect} conflicts with registered provider #{owner.inspect}"
434
+ end
435
+
436
+ def builtin_provider_name?(name)
437
+ BUILTIN_PROVIDER_DEFINITIONS.any? { |definition| definition[:name] == name }
438
+ end
439
+
440
+ def reserved_builtin_alias_owner(name)
441
+ definition = BUILTIN_PROVIDER_DEFINITIONS.find do |defn|
442
+ defn[:aliases].include?(name) && defn[:name] != name
443
+ end
444
+ definition&.dig(:name)
445
+ end
104
446
 
105
- raise ConfigurationError, "Provider class must include AgentHarness::Providers::Adapter or implement required class methods"
447
+ def fallback_provider_metadata(name, klass, aliases, refresh: false)
448
+ normalized_aliases = aliases
449
+ .filter_map do |alias_name|
450
+ normalized_alias = alias_name.to_s.strip
451
+ next if normalized_alias.empty?
452
+
453
+ normalized_alias.to_sym
454
+ end
455
+ .uniq
456
+ .reject { |alias_name| alias_name == name }
457
+ installation = if klass.respond_to?(:installation_contract)
458
+ Adapter.normalize_metadata_installation(
459
+ klass.installation_contract,
460
+ provider_name: name,
461
+ binary_name: klass.binary_name
462
+ )
463
+ end
464
+
465
+ {
466
+ provider: name,
467
+ canonical_provider: name,
468
+ aliases: normalized_aliases,
469
+ display_name: name.to_s.split("_").map(&:capitalize).join(" "),
470
+ binary_name: klass.binary_name,
471
+ auth: {
472
+ default_mode: nil,
473
+ supported_modes: [],
474
+ service: nil,
475
+ api_family: nil
476
+ },
477
+ runtime: {
478
+ interface: :cli,
479
+ requires_cli: true,
480
+ available: metadata_runtime_available(name, klass, refresh: refresh),
481
+ installable: !installation.nil?,
482
+ installation: installation,
483
+ prompt_delivery: nil,
484
+ output_format: nil,
485
+ sandbox_aware: nil,
486
+ uses_subcommand: nil,
487
+ supports_mcp: false,
488
+ supported_mcp_transports: [],
489
+ supports_sessions: false,
490
+ supports_dangerous_mode: false
491
+ },
492
+ configuration: {fields: [], auth_modes: [], openai_compatible: false},
493
+ capabilities: {streaming: false, file_upload: false, vision: false, tool_use: false, json_mode: false, mcp: false, dangerous_mode: false},
494
+ health_check: {
495
+ supports_registry_checks: false,
496
+ auth_check_supported: false,
497
+ provider_status: false,
498
+ configuration_validation: false,
499
+ lightweight: false
500
+ },
501
+ identity: {
502
+ bot_usernames: [name, *normalized_aliases]
503
+ .filter_map do |identity|
504
+ normalized_identity = identity.to_s.strip
505
+ normalized_identity unless normalized_identity.empty?
506
+ end
507
+ .uniq
508
+ }
509
+ }
510
+ end
511
+
512
+ def metadata_runtime_available(name, klass, refresh: false)
513
+ if refresh || !@metadata_runtime_available.key?(name)
514
+ @metadata_runtime_available[name] = klass.available?
515
+ end
516
+
517
+ @metadata_runtime_available[name]
106
518
  end
107
519
 
108
520
  def ensure_builtin_providers_registered
@@ -113,26 +525,42 @@ module AgentHarness
113
525
  end
114
526
 
115
527
  def register_builtin_providers
116
- # Only register providers that exist
117
- # These will be loaded on demand
118
- register_if_available(:claude, "agent_harness/providers/anthropic", :Anthropic, aliases: [:anthropic])
119
- register_if_available(:cursor, "agent_harness/providers/cursor", :Cursor)
120
- register_if_available(:gemini, "agent_harness/providers/gemini", :Gemini)
121
- register_if_available(:github_copilot, "agent_harness/providers/github_copilot", :GithubCopilot, aliases: [:copilot])
122
- register_if_available(:codex, "agent_harness/providers/codex", :Codex)
123
- register_if_available(:opencode, "agent_harness/providers/opencode", :Opencode)
124
- register_if_available(:kilocode, "agent_harness/providers/kilocode", :Kilocode)
125
- register_if_available(:aider, "agent_harness/providers/aider", :Aider)
126
- register_if_available(:mistral_vibe, "agent_harness/providers/mistral_vibe", :MistralVibe)
528
+ @builtin_registration_in_progress = true
529
+ BUILTIN_PROVIDER_DEFINITIONS.each do |definition|
530
+ definition_name = definition[:name]
531
+ next if builtin_provider_name_taken?(definition_name)
532
+
533
+ register_if_available(
534
+ definition_name,
535
+ definition[:require_path],
536
+ definition[:class_name],
537
+ aliases: definition[:aliases]
538
+ )
539
+ end
540
+ ensure
541
+ @builtin_registration_in_progress = false
542
+ end
543
+
544
+ def builtin_provider_name_taken?(name)
545
+ @providers.key?(name)
127
546
  end
128
547
 
129
548
  def register_if_available(name, require_path, class_name, aliases: [])
130
549
  require_relative require_path.sub("agent_harness/providers/", "")
131
550
  klass = AgentHarness::Providers.const_get(class_name)
132
- register(name, klass, aliases: aliases)
551
+ register(name, klass, aliases: builtin_aliases_for(name, aliases))
133
552
  rescue LoadError, NameError => e
134
553
  AgentHarness.logger&.debug("[AgentHarness::Registry] Provider #{name} not available: #{e.message}")
135
554
  end
555
+
556
+ def builtin_aliases_for(name, aliases)
557
+ Array(aliases).reject do |alias_name|
558
+ alias_key = alias_name.to_sym
559
+ next false if alias_key == name
560
+
561
+ @providers.key?(alias_key) || @aliases.key?(alias_key)
562
+ end
563
+ end
136
564
  end
137
565
  end
138
566
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.5.6"
4
+ VERSION = "0.5.8"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -70,10 +70,11 @@ module AgentHarness
70
70
  # Send a message using the orchestration layer
71
71
  # @param prompt [String] the prompt to send
72
72
  # @param provider [Symbol, nil] optional provider override
73
+ # @param executor [CommandExecutor, nil] per-request executor override
73
74
  # @param options [Hash] additional options
74
75
  # @return [Response] the response from the provider
75
- def send_message(prompt, provider: nil, **options)
76
- conductor.send_message(prompt, provider: provider, **options)
76
+ def send_message(prompt, provider: nil, executor: nil, **options)
77
+ conductor.send_message(prompt, provider: provider, executor: executor, **options)
77
78
  end
78
79
 
79
80
  # Get a provider instance
@@ -83,6 +84,92 @@ module AgentHarness
83
84
  conductor.provider_manager.get_provider(name)
84
85
  end
85
86
 
87
+ # Get install contract metadata for a provider
88
+ # @param name [Symbol, String] the provider name
89
+ # @return [Hash] install contract metadata
90
+ # @raise [ConfigurationError] if the provider does not expose an install contract
91
+ def install_contract(name)
92
+ Providers::Registry.instance.install_contract(name)
93
+ end
94
+
95
+ # Returns install metadata for a provider CLI when the provider exposes it.
96
+ #
97
+ # @param provider_name [Symbol, String] the provider name
98
+ # @param version [String, nil] optional explicit CLI version override
99
+ # @return [Hash, nil] installation metadata
100
+ def provider_install_contract(provider_name, version: nil)
101
+ provider_installation_contract(provider_name, **(version ? {version: version} : {}))
102
+ end
103
+
104
+ # Get the installation contract for a provider CLI.
105
+ #
106
+ # @param name [Symbol, String] the provider name
107
+ # @param options [Hash] optional target selection (for example, `version:`)
108
+ # @return [Hash, nil] provider installation contract for the requested target
109
+ # @raise [ConfigurationError] if provider not found
110
+ def provider_installation_contract(name, **options)
111
+ Providers::Registry.instance.installation_contract(name, **options)
112
+ end
113
+
114
+ # Get installation metadata for a provider CLI.
115
+ # @param provider_name [Symbol, String] the provider name
116
+ # @param options [Hash] optional target selection (for example, `version:`)
117
+ # @return [Hash, nil] installation contract
118
+ # @raise [ConfigurationError] if the provider name is not registered
119
+ def installation_contract(provider_name, **options)
120
+ Providers::Registry.instance.installation_contract(provider_name, **options)
121
+ end
122
+
123
+ # Get all provider installation contracts exposed by agent-harness.
124
+ # @return [Hash<Symbol, Hash>] installation contracts keyed by provider
125
+ def installation_contracts
126
+ Providers::Registry.instance.installation_contracts
127
+ end
128
+
129
+ # Get consolidated metadata for a provider.
130
+ #
131
+ # @param provider_name [Symbol, String] the provider name or alias
132
+ # @param refresh [Boolean] when true, refresh live runtime metadata such as
133
+ # CLI availability instead of reusing cached values
134
+ # @return [Hash] provider metadata
135
+ # @raise [ConfigurationError] if the provider name is not registered
136
+ def provider_metadata(provider_name, refresh: false)
137
+ Providers::Registry.instance.provider_metadata(provider_name, refresh: refresh)
138
+ end
139
+
140
+ # Get consolidated metadata for all registered providers.
141
+ #
142
+ # @param refresh [Boolean] when true, refresh live runtime metadata such as
143
+ # CLI availability instead of reusing cached values
144
+ # @return [Hash<Symbol, Hash>] provider metadata keyed by canonical provider
145
+ def provider_metadata_catalog(refresh: false)
146
+ Providers::Registry.instance.provider_metadata_catalog(refresh: refresh)
147
+ end
148
+
149
+ # Get smoke-test metadata for a provider CLI when the provider exposes it.
150
+ #
151
+ # @param provider_name [Symbol, String] the provider name
152
+ # @return [Hash, nil] smoke-test contract
153
+ def provider_smoke_test_contract(provider_name)
154
+ smoke_test_contract(provider_name)
155
+ end
156
+
157
+ # Get smoke-test metadata for a provider CLI.
158
+ # @param provider_name [Symbol, String] the provider name
159
+ # @return [Hash, nil] smoke-test contract
160
+ # @raise [ConfigurationError] if the provider name is not registered
161
+ def smoke_test_contract(provider_name)
162
+ # Explicitly raise if provider is not registered to match documentation
163
+ raise ConfigurationError, "Unknown provider: #{provider_name}" unless Providers::Registry.instance.registered?(provider_name)
164
+ Providers::Registry.instance.smoke_test_contract(provider_name)
165
+ end
166
+
167
+ # Get all provider smoke-test contracts exposed by agent-harness.
168
+ # @return [Hash<Symbol, Hash>] smoke-test contracts keyed by provider
169
+ def smoke_test_contracts
170
+ Providers::Registry.instance.smoke_test_contracts
171
+ end
172
+
86
173
  # Check if authentication is valid for a provider
87
174
  # @param provider_name [Symbol] the provider name
88
175
  # @return [Boolean] true if auth is valid
@@ -120,17 +207,29 @@ module AgentHarness
120
207
  # authentication, provider health status, and config validation checks.
121
208
  #
122
209
  # @param timeout [Integer] timeout in seconds for each check (defaults to configured value)
210
+ # @raise [ArgumentError] if provider_runtime is supplied; runtime overrides are
211
+ # only supported by `check_provider` to avoid leaking one provider's execution
212
+ # context into every other health check
123
213
  # @return [Array<Hash>] health status for each provider
124
- def check_providers(timeout: nil)
125
- timeout ? ProviderHealthCheck.check_all(timeout: timeout) : ProviderHealthCheck.check_all
214
+ def check_providers(timeout: nil, executor: nil, provider_runtime: nil)
215
+ raise ArgumentError, "provider_runtime is only supported for single-provider health checks" unless provider_runtime.nil?
216
+
217
+ options = {}
218
+ options[:timeout] = timeout unless timeout.nil?
219
+ options[:executor] = executor unless executor.nil?
220
+ ProviderHealthCheck.check_all(**options)
126
221
  end
127
222
 
128
223
  # Check health of a single provider
129
224
  # @param provider_name [Symbol] the provider name
130
225
  # @param timeout [Integer, nil] timeout in seconds (nil lets ProviderHealthCheck apply its validated default)
131
226
  # @return [Hash] health status with :name, :status, :message, :latency_ms
132
- def check_provider(provider_name, timeout: nil)
133
- timeout ? ProviderHealthCheck.check(provider_name, timeout: timeout) : ProviderHealthCheck.check(provider_name)
227
+ def check_provider(provider_name, timeout: nil, executor: nil, provider_runtime: nil)
228
+ options = {}
229
+ options[:timeout] = timeout unless timeout.nil?
230
+ options[:executor] = executor unless executor.nil?
231
+ options[:provider_runtime] = provider_runtime unless provider_runtime.nil?
232
+ ProviderHealthCheck.check(provider_name, **options)
134
233
  end
135
234
  end
136
235
  end