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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +26 -0
- data/README.md +216 -3
- data/lib/agent_harness/authentication.rb +28 -9
- data/lib/agent_harness/command_executor.rb +453 -32
- data/lib/agent_harness/docker_command_executor.rb +23 -3
- data/lib/agent_harness/error_taxonomy.rb +10 -0
- data/lib/agent_harness/errors.rb +5 -0
- data/lib/agent_harness/orchestration/conductor.rb +40 -16
- data/lib/agent_harness/orchestration/provider_manager.rb +46 -18
- data/lib/agent_harness/provider_health_check.rb +243 -63
- data/lib/agent_harness/provider_runtime.rb +20 -3
- data/lib/agent_harness/providers/adapter.rb +717 -0
- data/lib/agent_harness/providers/aider.rb +59 -0
- data/lib/agent_harness/providers/anthropic.rb +98 -0
- data/lib/agent_harness/providers/base.rb +46 -10
- data/lib/agent_harness/providers/codex.rb +68 -9
- data/lib/agent_harness/providers/cursor.rb +90 -2
- data/lib/agent_harness/providers/gemini.rb +43 -0
- data/lib/agent_harness/providers/github_copilot.rb +38 -6
- data/lib/agent_harness/providers/kilocode.rb +39 -0
- data/lib/agent_harness/providers/mistral_vibe.rb +13 -0
- data/lib/agent_harness/providers/opencode.rb +77 -1
- data/lib/agent_harness/providers/registry.rb +446 -18
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +105 -6
- metadata +21 -1
|
@@ -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
|
-
|
|
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
|
-
#
|
|
134
|
+
# Fetch install contract metadata for a provider.
|
|
83
135
|
#
|
|
84
|
-
# @
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
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
|
-
|
|
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
|
-
|
|
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
|