agent-harness 0.5.7 → 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,6 +131,27 @@ module AgentHarness
79
131
  @providers.select { |_, klass| klass.available? }.keys
80
132
  end
81
133
 
134
+ # Fetch install contract metadata for a provider.
135
+ #
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
+
82
155
  # Fetch installation metadata for a provider.
83
156
  #
84
157
  # @param name [Symbol, String] the provider name
@@ -133,30 +206,315 @@ module AgentHarness
133
206
  end
134
207
  end
135
208
 
136
- # Reset registry (useful for testing)
209
+ # Fetch consolidated provider metadata for a provider.
137
210
  #
138
- # @return [void]
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
+
139
255
  def reset!
256
+ @providers.each_value { |klass| clear_metadata_caches!(klass) }
140
257
  @providers.clear
141
258
  @aliases.clear
259
+ @provider_aliases.clear
260
+ @metadata_runtime_available.clear
261
+ clear_registry_metadata_cache!
142
262
  @builtin_registered = false
263
+ @builtin_registration_in_progress = false
143
264
  end
144
265
 
145
266
  private
146
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
+
147
294
  def resolve_alias(name)
148
295
  @aliases[name] || name
149
296
  end
150
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
+
151
367
  def validate_provider_class!(klass)
152
368
  includes_adapter = klass.include?(Adapter)
153
369
  has_required_methods = klass.respond_to?(:provider_name) &&
154
370
  klass.respond_to?(:available?) &&
155
371
  klass.respond_to?(:binary_name)
156
372
 
157
- 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
158
417
 
159
- raise ConfigurationError, "Provider class must include AgentHarness::Providers::Adapter or implement required class methods"
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
446
+
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]
160
518
  end
161
519
 
162
520
  def ensure_builtin_providers_registered
@@ -167,26 +525,42 @@ module AgentHarness
167
525
  end
168
526
 
169
527
  def register_builtin_providers
170
- # Only register providers that exist
171
- # These will be loaded on demand
172
- register_if_available(:claude, "agent_harness/providers/anthropic", :Anthropic, aliases: [:anthropic])
173
- register_if_available(:cursor, "agent_harness/providers/cursor", :Cursor)
174
- register_if_available(:gemini, "agent_harness/providers/gemini", :Gemini)
175
- register_if_available(:github_copilot, "agent_harness/providers/github_copilot", :GithubCopilot, aliases: [:copilot])
176
- register_if_available(:codex, "agent_harness/providers/codex", :Codex)
177
- register_if_available(:opencode, "agent_harness/providers/opencode", :Opencode)
178
- register_if_available(:kilocode, "agent_harness/providers/kilocode", :Kilocode)
179
- register_if_available(:aider, "agent_harness/providers/aider", :Aider)
180
- 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)
181
546
  end
182
547
 
183
548
  def register_if_available(name, require_path, class_name, aliases: [])
184
549
  require_relative require_path.sub("agent_harness/providers/", "")
185
550
  klass = AgentHarness::Providers.const_get(class_name)
186
- register(name, klass, aliases: aliases)
551
+ register(name, klass, aliases: builtin_aliases_for(name, aliases))
187
552
  rescue LoadError, NameError => e
188
553
  AgentHarness.logger&.debug("[AgentHarness::Registry] Provider #{name} not available: #{e.message}")
189
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
190
564
  end
191
565
  end
192
566
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.5.7"
4
+ VERSION = "0.5.8"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -84,6 +84,14 @@ module AgentHarness
84
84
  conductor.provider_manager.get_provider(name)
85
85
  end
86
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
+
87
95
  # Returns install metadata for a provider CLI when the provider exposes it.
88
96
  #
89
97
  # @param provider_name [Symbol, String] the provider name
@@ -118,6 +126,26 @@ module AgentHarness
118
126
  Providers::Registry.instance.installation_contracts
119
127
  end
120
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
+
121
149
  # Get smoke-test metadata for a provider CLI when the provider exposes it.
122
150
  #
123
151
  # @param provider_name [Symbol, String] the provider name
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.5.7
4
+ version: 0.5.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan