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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +10 -0
- data/README.md +140 -2
- data/lib/agent_harness/authentication.rb +28 -9
- data/lib/agent_harness/orchestration/provider_manager.rb +26 -6
- data/lib/agent_harness/provider_health_check.rb +28 -6
- data/lib/agent_harness/providers/adapter.rb +589 -8
- data/lib/agent_harness/providers/aider.rb +55 -0
- data/lib/agent_harness/providers/anthropic.rb +94 -0
- data/lib/agent_harness/providers/codex.rb +19 -4
- data/lib/agent_harness/providers/cursor.rb +73 -1
- data/lib/agent_harness/providers/gemini.rb +9 -0
- data/lib/agent_harness/providers/github_copilot.rb +12 -0
- data/lib/agent_harness/providers/mistral_vibe.rb +9 -0
- data/lib/agent_harness/providers/opencode.rb +9 -0
- data/lib/agent_harness/providers/registry.rb +392 -18
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +28 -0
- metadata +1 -1
|
@@ -16,12 +16,71 @@ module AgentHarness
|
|
|
16
16
|
# end
|
|
17
17
|
# end
|
|
18
18
|
module Adapter
|
|
19
|
+
def self.normalize_metadata_installation(contract, provider_name:, binary_name:)
|
|
20
|
+
return nil unless contract.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
source = contract[:source]
|
|
23
|
+
install_command = contract[:install_command]&.dup
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
provider: provider_name.to_sym,
|
|
27
|
+
source_type: normalize_metadata_source_type(contract[:source_type] || source),
|
|
28
|
+
package_name: metadata_package_name(contract, source),
|
|
29
|
+
default_version: contract[:default_version] || contract[:version] || contract[:resolved_version],
|
|
30
|
+
resolved_version: contract[:resolved_version] || contract[:version] || contract[:default_version],
|
|
31
|
+
supported_version_requirement: normalize_metadata_version_requirement(
|
|
32
|
+
contract[:supported_version_requirement] || contract[:version_requirement]
|
|
33
|
+
),
|
|
34
|
+
binary_name: contract[:binary_name] || binary_name,
|
|
35
|
+
install_command: install_command,
|
|
36
|
+
install_command_string: contract[:install_command_string] || install_command&.join(" ")
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.normalize_metadata_source_type(source)
|
|
41
|
+
return source[:type]&.to_sym if source.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
source&.to_sym
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.metadata_package_name(contract, source)
|
|
47
|
+
return contract[:package_name] if contract[:package_name]
|
|
48
|
+
return source[:package] if source.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
package = contract[:package]
|
|
51
|
+
return package unless package.is_a?(String)
|
|
52
|
+
|
|
53
|
+
if package.split("@").first == ""
|
|
54
|
+
package.split("@", 3).first(2).join("@")
|
|
55
|
+
else
|
|
56
|
+
package.split("@", 2).first
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.normalize_metadata_version_requirement(requirement)
|
|
61
|
+
case requirement
|
|
62
|
+
when nil
|
|
63
|
+
nil
|
|
64
|
+
when Array
|
|
65
|
+
if requirement.all? { |entry| entry.is_a?(Array) && entry.length == 2 }
|
|
66
|
+
requirement.map { |operator, version| "#{operator} #{version}" }.join(", ")
|
|
67
|
+
else
|
|
68
|
+
requirement.join(", ")
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
requirement.to_s
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
19
75
|
def self.included(base)
|
|
20
76
|
base.extend(ClassMethods)
|
|
21
77
|
end
|
|
22
78
|
|
|
23
79
|
# Class methods that all providers must implement
|
|
24
80
|
module ClassMethods
|
|
81
|
+
SUPPORTED_OAUTH_AUTH_STATUS_PROVIDERS = %i[anthropic claude].freeze
|
|
82
|
+
IMMUTABLE_METADATA_OVERRIDE_KEYS = %i[provider canonical_provider aliases binary_name].freeze
|
|
83
|
+
|
|
25
84
|
# Human-readable provider name
|
|
26
85
|
#
|
|
27
86
|
# @return [Symbol] unique identifier for this provider
|
|
@@ -75,37 +134,521 @@ module AgentHarness
|
|
|
75
134
|
[]
|
|
76
135
|
end
|
|
77
136
|
|
|
78
|
-
#
|
|
137
|
+
# First-class install metadata for the provider CLI.
|
|
138
|
+
#
|
|
139
|
+
# Downstream applications can use this metadata to build provider
|
|
140
|
+
# images without hardcoding provider-specific install URLs, expected
|
|
141
|
+
# binary paths, or supported install targets.
|
|
142
|
+
#
|
|
143
|
+
# This is separate from .installation_contract, which serves
|
|
144
|
+
# package-driven CLIs. Providers that need richer install metadata
|
|
145
|
+
# (e.g. shell-script installers, checksums, artifact URLs) should
|
|
146
|
+
# override this method.
|
|
147
|
+
#
|
|
148
|
+
# @param version [String, Symbol, nil] optional install target/version
|
|
149
|
+
# @return [Hash, nil] provider install metadata, or nil when the
|
|
150
|
+
# provider does not expose a first-class install contract
|
|
151
|
+
def install_metadata(version: nil)
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Installation contract for package-driven provider CLIs.
|
|
79
156
|
#
|
|
80
157
|
# Downstream apps can use this metadata to provision the provider CLI
|
|
81
158
|
# without hardcoding package names, versions, or binary expectations
|
|
82
159
|
# outside agent-harness.
|
|
83
160
|
#
|
|
84
|
-
# @return [Hash, nil] install metadata, or nil when no
|
|
161
|
+
# @return [Hash, nil] install metadata, or nil when no package-based
|
|
85
162
|
# installation contract is defined for the provider
|
|
86
163
|
def installation_contract(**options)
|
|
87
164
|
return install_contract unless options.key?(:version)
|
|
88
165
|
|
|
89
|
-
install_contract
|
|
166
|
+
# Check if install_contract accepts the version: keyword before
|
|
167
|
+
# forwarding it; legacy providers may override install_contract
|
|
168
|
+
# without that parameter, which would raise ArgumentError.
|
|
169
|
+
params = method(:install_contract).parameters
|
|
170
|
+
accepts_version = params.any? do |type, name|
|
|
171
|
+
# Only treat an explicit `version:` keyword as proof the method
|
|
172
|
+
# handles version selection. A bare `**options` keyrest does not
|
|
173
|
+
# count — providers may add it for forward-compatibility without
|
|
174
|
+
# actually acting on the version value.
|
|
175
|
+
[:key, :keyreq].include?(type) && name == :version
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if accepts_version
|
|
179
|
+
install_contract(version: options[:version])
|
|
180
|
+
else
|
|
181
|
+
install_contract
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Stable provider metadata for downstream configuration and policy UIs.
|
|
186
|
+
#
|
|
187
|
+
# This contract consolidates provider identifier aliases, auth/runtime
|
|
188
|
+
# details, installability, and health-check characteristics so apps do
|
|
189
|
+
# not need to maintain their own partial mirrors of adapter behavior.
|
|
190
|
+
#
|
|
191
|
+
# @param aliases [Array<Symbol, String>] alternate identifiers registered
|
|
192
|
+
# for this provider
|
|
193
|
+
# @param requested_name [Symbol, String] provider identifier originally
|
|
194
|
+
# requested by the caller; used to prefer alias-keyed config when
|
|
195
|
+
# metadata construction is config-sensitive
|
|
196
|
+
# @param canonical_name [Symbol, String] canonical registry identifier
|
|
197
|
+
# for this provider; used for the public stable metadata contract
|
|
198
|
+
# @return [Hash] provider metadata
|
|
199
|
+
def provider_metadata(aliases: [], refresh: false, requested_name: provider_name, canonical_name: provider_name)
|
|
200
|
+
normalized_aliases = normalize_metadata_aliases(aliases, canonical_name: canonical_name)
|
|
201
|
+
requested_provider_name = requested_name.to_sym
|
|
202
|
+
canonical_provider_name = canonical_name.to_sym
|
|
203
|
+
provider = metadata_provider_instance(
|
|
204
|
+
requested_name: requested_provider_name,
|
|
205
|
+
canonical_name: canonical_provider_name
|
|
206
|
+
)
|
|
207
|
+
configuration = deep_merge_metadata(
|
|
208
|
+
default_configuration_schema,
|
|
209
|
+
provider_metadata_hash(provider, :configuration_schema, default: {})
|
|
210
|
+
)
|
|
211
|
+
execution = deep_merge_metadata(
|
|
212
|
+
default_execution_semantics,
|
|
213
|
+
provider_metadata_hash(provider, :execution_semantics, default: {})
|
|
214
|
+
)
|
|
215
|
+
installation = Adapter.normalize_metadata_installation(
|
|
216
|
+
installation_contract,
|
|
217
|
+
provider_name: canonical_provider_name,
|
|
218
|
+
binary_name: binary_name
|
|
219
|
+
)
|
|
220
|
+
supported_auth_modes = Array(configuration[:auth_modes]).map(&:to_sym)
|
|
221
|
+
supports_registry_checks = !provider.nil?
|
|
222
|
+
auth_check_supported = auth_status_available?(
|
|
223
|
+
provider,
|
|
224
|
+
requested_name: requested_provider_name,
|
|
225
|
+
canonical_name: canonical_provider_name,
|
|
226
|
+
refresh: refresh
|
|
227
|
+
)
|
|
228
|
+
provider_status_check = supports_registry_checks && overrides_instance_method?(:health_status)
|
|
229
|
+
configuration_validation = supports_registry_checks && overrides_instance_method?(:validate_config)
|
|
230
|
+
lightweight_checks = supports_registry_checks && !provider_status_check && !configuration_validation
|
|
231
|
+
|
|
232
|
+
metadata = {
|
|
233
|
+
provider: canonical_provider_name,
|
|
234
|
+
canonical_provider: canonical_provider_name,
|
|
235
|
+
aliases: normalized_aliases,
|
|
236
|
+
display_name: provider_display_name(provider, canonical_name: canonical_provider_name),
|
|
237
|
+
binary_name: binary_name,
|
|
238
|
+
auth: {
|
|
239
|
+
default_mode: metadata_default_auth_mode(provider, supported_modes: supported_auth_modes),
|
|
240
|
+
supported_modes: supported_auth_modes,
|
|
241
|
+
service: nil,
|
|
242
|
+
api_family: nil
|
|
243
|
+
},
|
|
244
|
+
runtime: {
|
|
245
|
+
interface: :cli,
|
|
246
|
+
requires_cli: true,
|
|
247
|
+
available: metadata_runtime_available(refresh: refresh),
|
|
248
|
+
installable: !installation.nil?,
|
|
249
|
+
installation: installation,
|
|
250
|
+
prompt_delivery: execution[:prompt_delivery],
|
|
251
|
+
output_format: execution[:output_format],
|
|
252
|
+
sandbox_aware: execution[:sandbox_aware],
|
|
253
|
+
uses_subcommand: execution[:uses_subcommand],
|
|
254
|
+
supports_mcp: provider_metadata_value(provider, :supports_mcp?, default: default_supports_mcp),
|
|
255
|
+
supported_mcp_transports: provider_metadata_value(
|
|
256
|
+
provider,
|
|
257
|
+
:supported_mcp_transports,
|
|
258
|
+
default: default_supported_mcp_transports
|
|
259
|
+
),
|
|
260
|
+
supports_sessions: provider_metadata_value(
|
|
261
|
+
provider,
|
|
262
|
+
:supports_sessions?,
|
|
263
|
+
default: default_supports_sessions
|
|
264
|
+
),
|
|
265
|
+
supports_dangerous_mode: provider_metadata_value(
|
|
266
|
+
provider,
|
|
267
|
+
:supports_dangerous_mode?,
|
|
268
|
+
default: default_supports_dangerous_mode
|
|
269
|
+
)
|
|
270
|
+
},
|
|
271
|
+
configuration: configuration,
|
|
272
|
+
capabilities: deep_merge_metadata(
|
|
273
|
+
default_capabilities,
|
|
274
|
+
provider_metadata_hash(provider, :capabilities, default: {})
|
|
275
|
+
),
|
|
276
|
+
health_check: {
|
|
277
|
+
supports_registry_checks: supports_registry_checks,
|
|
278
|
+
auth_check_supported: auth_check_supported,
|
|
279
|
+
provider_status: provider_status_check,
|
|
280
|
+
configuration_validation: configuration_validation,
|
|
281
|
+
lightweight: lightweight_checks
|
|
282
|
+
},
|
|
283
|
+
identity: {
|
|
284
|
+
bot_usernames: provider_bot_usernames(
|
|
285
|
+
canonical_name: canonical_provider_name,
|
|
286
|
+
aliases: normalized_aliases
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
deep_merge_metadata(metadata, sanitized_provider_metadata_overrides)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Optional provider-specific metadata overrides for provider_metadata.
|
|
295
|
+
#
|
|
296
|
+
# @return [Hash]
|
|
297
|
+
def provider_metadata_overrides
|
|
298
|
+
{}
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private
|
|
302
|
+
|
|
303
|
+
def normalize_metadata_aliases(aliases, canonical_name: provider_name)
|
|
304
|
+
canonical_provider_name = canonical_name.to_sym
|
|
305
|
+
|
|
306
|
+
Array(aliases)
|
|
307
|
+
.filter_map do |alias_name|
|
|
308
|
+
normalized_alias = alias_name.to_s.strip
|
|
309
|
+
next if normalized_alias.empty?
|
|
310
|
+
|
|
311
|
+
normalized_alias.to_sym
|
|
312
|
+
end
|
|
313
|
+
.uniq
|
|
314
|
+
.reject { |alias_name| alias_name == canonical_provider_name }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def provider_bot_usernames(canonical_name: provider_name, aliases: [])
|
|
318
|
+
[canonical_name, *aliases]
|
|
319
|
+
.filter_map do |identity|
|
|
320
|
+
normalized_identity = identity.to_s.strip
|
|
321
|
+
normalized_identity unless normalized_identity.empty?
|
|
322
|
+
end
|
|
323
|
+
.uniq
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def metadata_provider_instance(requested_name: provider_name, canonical_name: provider_name)
|
|
327
|
+
build_provider_instance(
|
|
328
|
+
config: metadata_provider_config(requested_name, canonical_name: canonical_name),
|
|
329
|
+
executor: AgentHarness.configuration.command_executor,
|
|
330
|
+
logger: AgentHarness.logger
|
|
331
|
+
)
|
|
332
|
+
rescue => e
|
|
333
|
+
AgentHarness.logger&.debug(
|
|
334
|
+
"[AgentHarness::Providers::Adapter] Falling back to default metadata for #{provider_name}: #{e.class}"
|
|
335
|
+
)
|
|
336
|
+
nil
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def safe_metadata_provider_instance(requested_name: provider_name, canonical_name: provider_name)
|
|
340
|
+
build_provider_instance(
|
|
341
|
+
config: metadata_provider_config(requested_name, canonical_name: canonical_name),
|
|
342
|
+
executor: AgentHarness.configuration.command_executor,
|
|
343
|
+
logger: AgentHarness.logger
|
|
344
|
+
)
|
|
345
|
+
rescue
|
|
346
|
+
# Return nil without logging - caller is responsible for handling
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def build_provider_instance(config: nil, executor: nil, logger: nil)
|
|
351
|
+
kwargs = provider_instance_kwargs(config: config, executor: executor, logger: logger)
|
|
352
|
+
|
|
353
|
+
if metadata_initializer_compatible? && !legacy_positional_initializer?
|
|
354
|
+
new(**kwargs)
|
|
355
|
+
else
|
|
356
|
+
# Preserve backwards compatibility with legacy positional constructors
|
|
357
|
+
# that accept arguments via a splat (e.g. initialize(*args)).
|
|
358
|
+
new(config: config, executor: executor, logger: logger)
|
|
359
|
+
end
|
|
360
|
+
rescue ArgumentError => e
|
|
361
|
+
# Only retry for signature-mismatch errors (wrong number/type of
|
|
362
|
+
# arguments), not for ArgumentError raised by real validation inside
|
|
363
|
+
# the initializer, which would duplicate side effects or expensive
|
|
364
|
+
# setup on the second call.
|
|
365
|
+
raise unless e.message.match?(/wrong number of arguments|unknown keyword|missing keyword/i)
|
|
366
|
+
|
|
367
|
+
new(config: config, executor: executor, logger: logger)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def provider_instance_kwargs(config: nil, executor: nil, logger: nil)
|
|
371
|
+
parameters = instance_method(:initialize).parameters
|
|
372
|
+
accepts = lambda do |name|
|
|
373
|
+
parameters.any? { |type, param_name| [:key, :keyreq].include?(type) && param_name == name } ||
|
|
374
|
+
parameters.any? { |type, _| type == :keyrest }
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
kwargs = {}
|
|
378
|
+
kwargs[:config] = config if accepts.call(:config)
|
|
379
|
+
kwargs[:executor] = executor if accepts.call(:executor)
|
|
380
|
+
kwargs[:logger] = logger if accepts.call(:logger)
|
|
381
|
+
|
|
382
|
+
kwargs
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def legacy_positional_initializer?
|
|
386
|
+
instance_method(:initialize).parameters.any? do |type, _|
|
|
387
|
+
# Splat (*args) or optional positional (e.g. config = nil) params
|
|
388
|
+
# indicate a legacy constructor that should receive the full
|
|
389
|
+
# config:/executor:/logger: keyword set directly.
|
|
390
|
+
type == :rest || type == :opt
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def metadata_provider_config(requested_name, canonical_name: provider_name)
|
|
395
|
+
requested_provider_name = requested_name.to_sym
|
|
396
|
+
canonical_provider_name = canonical_name.to_sym
|
|
397
|
+
|
|
398
|
+
AgentHarness.configuration.providers[requested_provider_name] ||
|
|
399
|
+
AgentHarness.configuration.providers[canonical_provider_name] ||
|
|
400
|
+
AgentHarness::ProviderConfig.new(requested_provider_name)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def metadata_initializer_compatible?
|
|
404
|
+
required_keywords = initializer_required_keywords
|
|
405
|
+
return false if instance_method(:initialize).parameters.any? { |type, _name| type == :req }
|
|
406
|
+
|
|
407
|
+
(required_keywords - supported_initializer_keywords).empty?
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Check if this provider has auth_status support available for health checks
|
|
411
|
+
#
|
|
412
|
+
# This differs from supports_registry_checks - it specifically indicates whether
|
|
413
|
+
# the auth status check will succeed or return "not implemented"
|
|
414
|
+
def auth_status_available?(
|
|
415
|
+
provider_instance = nil,
|
|
416
|
+
requested_name: provider_name,
|
|
417
|
+
canonical_name: provider_name,
|
|
418
|
+
refresh: false
|
|
419
|
+
)
|
|
420
|
+
@auth_status_available = {} unless instance_variable_defined?(:@auth_status_available)
|
|
421
|
+
cache_key = [requested_name.to_sym, canonical_name.to_sym]
|
|
422
|
+
return @auth_status_available[cache_key] if !refresh && @auth_status_available.key?(cache_key)
|
|
423
|
+
|
|
424
|
+
@auth_status_available[cache_key] = begin
|
|
425
|
+
provider_instance ||= safe_metadata_provider_instance(
|
|
426
|
+
requested_name: requested_name,
|
|
427
|
+
canonical_name: canonical_name
|
|
428
|
+
)
|
|
429
|
+
auth_status_supported_by?(
|
|
430
|
+
provider_instance,
|
|
431
|
+
requested_name: requested_name,
|
|
432
|
+
canonical_name: canonical_name
|
|
433
|
+
)
|
|
434
|
+
rescue
|
|
435
|
+
false
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def auth_status_supported_by?(provider_instance, requested_name: provider_name, canonical_name: provider_name)
|
|
440
|
+
return false unless provider_instance
|
|
441
|
+
|
|
442
|
+
if provider_instance.respond_to?(:auth_status) &&
|
|
443
|
+
provider_instance.method(:auth_status).owner != AgentHarness::Providers::Adapter
|
|
444
|
+
return true
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
return false unless provider_instance.respond_to?(:auth_type)
|
|
448
|
+
|
|
449
|
+
case provider_instance.auth_type
|
|
450
|
+
when :api_key
|
|
451
|
+
false
|
|
452
|
+
when :oauth
|
|
453
|
+
provider_class_name = if provider_instance.class.respond_to?(:provider_name)
|
|
454
|
+
provider_instance.class.provider_name.to_sym
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
return false unless SUPPORTED_OAUTH_AUTH_STATUS_PROVIDERS.include?(provider_class_name)
|
|
458
|
+
|
|
459
|
+
[requested_name, canonical_name]
|
|
460
|
+
.map(&:to_sym)
|
|
461
|
+
.any? { |name| SUPPORTED_OAUTH_AUTH_STATUS_PROVIDERS.include?(name) }
|
|
462
|
+
else
|
|
463
|
+
false
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def initializer_required_keywords
|
|
468
|
+
parameters = instance_method(:initialize).parameters
|
|
469
|
+
|
|
470
|
+
parameters.filter_map { |type, name| name if type == :keyreq }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def supported_initializer_keywords
|
|
474
|
+
%i[config executor logger]
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def metadata_runtime_available(refresh: false)
|
|
478
|
+
if refresh || !instance_variable_defined?(:@metadata_runtime_available)
|
|
479
|
+
@metadata_runtime_available = available?
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
@metadata_runtime_available
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def overrides_instance_method?(method_name)
|
|
486
|
+
instance_method(method_name).owner != AgentHarness::Providers::Adapter
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def deep_merge_metadata(base, overrides)
|
|
490
|
+
return base unless overrides.is_a?(Hash)
|
|
491
|
+
|
|
492
|
+
base.merge(overrides) do |_key, left, right|
|
|
493
|
+
if left.is_a?(Hash) && right.is_a?(Hash)
|
|
494
|
+
deep_merge_metadata(left, right)
|
|
495
|
+
else
|
|
496
|
+
right
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def sanitized_provider_metadata_overrides
|
|
502
|
+
overrides = provider_metadata_overrides
|
|
503
|
+
return {} unless overrides.is_a?(Hash)
|
|
504
|
+
|
|
505
|
+
overrides.each_with_object({}) do |(key, value), sanitized|
|
|
506
|
+
next if immutable_metadata_override_key?(key)
|
|
507
|
+
|
|
508
|
+
sanitized[key] = value
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def immutable_metadata_override_key?(key)
|
|
513
|
+
IMMUTABLE_METADATA_OVERRIDE_KEYS.include?(key.to_sym)
|
|
514
|
+
rescue NoMethodError
|
|
515
|
+
false
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def provider_metadata_hash(provider, method_name, default:)
|
|
519
|
+
value = provider_metadata_value(provider, method_name, default: default)
|
|
520
|
+
value.is_a?(Hash) ? value : default
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def provider_metadata_value(provider, method_name, default:)
|
|
524
|
+
return default unless provider
|
|
525
|
+
|
|
526
|
+
provider.public_send(method_name)
|
|
527
|
+
rescue => e
|
|
528
|
+
AgentHarness.logger&.debug(
|
|
529
|
+
"[AgentHarness::Providers::Adapter] Falling back to default #{method_name} metadata for #{provider_name}: #{e.class}"
|
|
530
|
+
)
|
|
531
|
+
default
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def provider_display_name(provider, canonical_name: provider_name)
|
|
535
|
+
if provider&.respond_to?(:display_name) &&
|
|
536
|
+
provider.method(:display_name).owner != AgentHarness::Providers::Base
|
|
537
|
+
return provider_metadata_value(
|
|
538
|
+
provider,
|
|
539
|
+
:display_name,
|
|
540
|
+
default: canonical_name.to_s.split("_").map(&:capitalize).join(" ")
|
|
541
|
+
)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
canonical_name.to_s.split("_").map(&:capitalize).join(" ")
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def metadata_default_auth_mode(provider, supported_modes:)
|
|
548
|
+
provider_auth_type = provider_metadata_value(provider, :auth_type, default: nil)&.to_sym
|
|
549
|
+
return provider_auth_type if provider_auth_type && supported_modes.include?(provider_auth_type)
|
|
550
|
+
return supported_modes.first unless supported_modes.empty?
|
|
551
|
+
|
|
552
|
+
provider_auth_type
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def default_configuration_schema
|
|
556
|
+
{
|
|
557
|
+
fields: [],
|
|
558
|
+
auth_modes: [default_auth_type],
|
|
559
|
+
openai_compatible: false
|
|
560
|
+
}
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def default_execution_semantics
|
|
564
|
+
{
|
|
565
|
+
prompt_delivery: :arg,
|
|
566
|
+
output_format: :text,
|
|
567
|
+
sandbox_aware: false,
|
|
568
|
+
uses_subcommand: false,
|
|
569
|
+
non_interactive_flag: nil,
|
|
570
|
+
legitimate_exit_codes: [0],
|
|
571
|
+
stderr_is_diagnostic: true,
|
|
572
|
+
parses_rate_limit_reset: false
|
|
573
|
+
}
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def default_auth_type
|
|
577
|
+
:api_key
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def default_capabilities
|
|
581
|
+
{
|
|
582
|
+
streaming: false,
|
|
583
|
+
file_upload: false,
|
|
584
|
+
vision: false,
|
|
585
|
+
tool_use: false,
|
|
586
|
+
json_mode: false,
|
|
587
|
+
mcp: false,
|
|
588
|
+
dangerous_mode: false
|
|
589
|
+
}
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def default_supports_mcp
|
|
593
|
+
false
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def default_supported_mcp_transports
|
|
597
|
+
[]
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def default_supports_sessions
|
|
601
|
+
false
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def default_supports_dangerous_mode
|
|
605
|
+
false
|
|
90
606
|
end
|
|
91
607
|
|
|
92
|
-
|
|
608
|
+
public
|
|
609
|
+
|
|
610
|
+
# Shell command for installing the provider CLI.
|
|
93
611
|
#
|
|
94
|
-
# @param version [String, nil] optional
|
|
95
|
-
# @return [Array<String>, nil]
|
|
96
|
-
# provider
|
|
612
|
+
# @param version [String, Symbol, nil] optional install target/version
|
|
613
|
+
# @return [String, Array<String>, nil] shell command or argv, or nil
|
|
614
|
+
# when the provider does not expose an install contract
|
|
97
615
|
def install_command(version: nil)
|
|
616
|
+
metadata = install_metadata(version: version)
|
|
617
|
+
command = metadata&.dig(:source, :command)
|
|
618
|
+
return command if command
|
|
619
|
+
|
|
98
620
|
contract = installation_contract
|
|
99
621
|
return nil unless contract
|
|
100
622
|
|
|
101
623
|
return contract[:install_command] unless version
|
|
102
624
|
|
|
625
|
+
versioned_contract = versioned_installation_contract(version)
|
|
626
|
+
if versioned_contract&.key?(:install_command)
|
|
627
|
+
return versioned_contract[:install_command]
|
|
628
|
+
end
|
|
629
|
+
|
|
103
630
|
package_name = contract[:package_name]
|
|
104
631
|
unless package_name
|
|
105
632
|
raise ArgumentError, "installation_contract must define :package_name when overriding version"
|
|
106
633
|
end
|
|
107
634
|
|
|
108
|
-
|
|
635
|
+
requirement = contract[:version_requirement]
|
|
636
|
+
if requirement
|
|
637
|
+
requirement_args = Array(requirement).map do |entry|
|
|
638
|
+
entry.is_a?(Array) ? "#{entry[0]} #{entry[1]}" : entry
|
|
639
|
+
end
|
|
640
|
+
parsed_requirement = Gem::Requirement.new(*requirement_args)
|
|
641
|
+
unless parsed_requirement.satisfied_by?(Gem::Version.new(version))
|
|
642
|
+
raise ArgumentError,
|
|
643
|
+
"Unsupported #{provider_name} CLI version #{version.inspect}; " \
|
|
644
|
+
"supported versions must satisfy #{parsed_requirement}"
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
version_format = contract.fetch(:version_format, "%{package_name}@%{version}")
|
|
649
|
+
package_with_version = format(version_format, package_name: package_name, version: version)
|
|
650
|
+
|
|
651
|
+
Array(contract[:install_command_prefix]) + [package_with_version]
|
|
109
652
|
end
|
|
110
653
|
|
|
111
654
|
# Canonical smoke-test contract for this provider.
|
|
@@ -117,6 +660,44 @@ module AgentHarness
|
|
|
117
660
|
def smoke_test_contract
|
|
118
661
|
nil
|
|
119
662
|
end
|
|
663
|
+
|
|
664
|
+
private
|
|
665
|
+
|
|
666
|
+
def versioned_installation_contract(version)
|
|
667
|
+
# Only reuse the provider's own contract when the provider actually
|
|
668
|
+
# implements version-aware logic. We require an explicit `version:`
|
|
669
|
+
# keyword parameter — a bare `**options` keyrest is NOT sufficient
|
|
670
|
+
# because providers may add it for forward-compatibility without
|
|
671
|
+
# actually acting on the version value.
|
|
672
|
+
#
|
|
673
|
+
# For providers that override installation_contract directly we
|
|
674
|
+
# inspect that method. When the default (keyrest) implementation is
|
|
675
|
+
# in use, the version support depends on install_contract, so we
|
|
676
|
+
# inspect that instead.
|
|
677
|
+
|
|
678
|
+
ic_params = method(:installation_contract).parameters
|
|
679
|
+
ic_accepts_version = ic_params.any? do |type, name|
|
|
680
|
+
[:key, :keyreq].include?(type) && name == :version
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# The default implementation in Adapter::ClassMethods uses **options
|
|
684
|
+
# and delegates to install_contract, so version support depends on
|
|
685
|
+
# install_contract's signature. Only fall through when the default is
|
|
686
|
+
# actually in use — a provider that overrides installation_contract
|
|
687
|
+
# with its own version: keyword (even combined with **options) should
|
|
688
|
+
# be trusted directly.
|
|
689
|
+
default_owner = Adapter::ClassMethods
|
|
690
|
+
if method(:installation_contract).owner == default_owner
|
|
691
|
+
ic_params = method(:install_contract).parameters
|
|
692
|
+
ic_accepts_version = ic_params.any? do |type, name|
|
|
693
|
+
[:key, :keyreq].include?(type) && name == :version
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
return unless ic_accepts_version
|
|
698
|
+
|
|
699
|
+
installation_contract(version: version)
|
|
700
|
+
end
|
|
120
701
|
end
|
|
121
702
|
|
|
122
703
|
# Instance methods
|