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
|
@@ -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
|
|
@@ -43,6 +102,17 @@ module AgentHarness
|
|
|
43
102
|
raise NotImplementedError, "#{self} must implement .binary_name"
|
|
44
103
|
end
|
|
45
104
|
|
|
105
|
+
# Installation contract for the provider CLI.
|
|
106
|
+
#
|
|
107
|
+
# Downstream applications can use this metadata to install a provider's
|
|
108
|
+
# supported CLI without hardcoding package names, install flags, or
|
|
109
|
+
# version pins outside AgentHarness.
|
|
110
|
+
#
|
|
111
|
+
# @return [Hash, nil] installation metadata or nil when not provided
|
|
112
|
+
def install_contract(version: nil)
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
46
116
|
# Required domains for firewall configuration
|
|
47
117
|
#
|
|
48
118
|
# @return [Hash] with :domains and :ip_ranges arrays
|
|
@@ -63,6 +133,571 @@ module AgentHarness
|
|
|
63
133
|
def discover_models
|
|
64
134
|
[]
|
|
65
135
|
end
|
|
136
|
+
|
|
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.
|
|
156
|
+
#
|
|
157
|
+
# Downstream apps can use this metadata to provision the provider CLI
|
|
158
|
+
# without hardcoding package names, versions, or binary expectations
|
|
159
|
+
# outside agent-harness.
|
|
160
|
+
#
|
|
161
|
+
# @return [Hash, nil] install metadata, or nil when no package-based
|
|
162
|
+
# installation contract is defined for the provider
|
|
163
|
+
def installation_contract(**options)
|
|
164
|
+
return install_contract unless options.key?(:version)
|
|
165
|
+
|
|
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
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
public
|
|
609
|
+
|
|
610
|
+
# Shell command for installing the provider CLI.
|
|
611
|
+
#
|
|
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
|
|
615
|
+
def install_command(version: nil)
|
|
616
|
+
metadata = install_metadata(version: version)
|
|
617
|
+
command = metadata&.dig(:source, :command)
|
|
618
|
+
return command if command
|
|
619
|
+
|
|
620
|
+
contract = installation_contract
|
|
621
|
+
return nil unless contract
|
|
622
|
+
|
|
623
|
+
return contract[:install_command] unless version
|
|
624
|
+
|
|
625
|
+
versioned_contract = versioned_installation_contract(version)
|
|
626
|
+
if versioned_contract&.key?(:install_command)
|
|
627
|
+
return versioned_contract[:install_command]
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
package_name = contract[:package_name]
|
|
631
|
+
unless package_name
|
|
632
|
+
raise ArgumentError, "installation_contract must define :package_name when overriding version"
|
|
633
|
+
end
|
|
634
|
+
|
|
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]
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# Canonical smoke-test contract for this provider.
|
|
655
|
+
#
|
|
656
|
+
# CLI-backed providers should expose a minimal real-execution prompt so
|
|
657
|
+
# downstream apps can reuse a stable provider-owned health check.
|
|
658
|
+
#
|
|
659
|
+
# @return [Hash, nil] smoke-test metadata or nil when not provided
|
|
660
|
+
def smoke_test_contract
|
|
661
|
+
nil
|
|
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
|
|
66
701
|
end
|
|
67
702
|
|
|
68
703
|
# Instance methods
|
|
@@ -240,6 +875,71 @@ module AgentHarness
|
|
|
240
875
|
{healthy: true, message: "OK"}
|
|
241
876
|
end
|
|
242
877
|
|
|
878
|
+
# Canonical smoke-test contract for this provider instance.
|
|
879
|
+
#
|
|
880
|
+
# @return [Hash, nil] smoke-test metadata
|
|
881
|
+
def smoke_test_contract
|
|
882
|
+
self.class.smoke_test_contract if self.class.respond_to?(:smoke_test_contract)
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
# Execute a minimal provider-owned smoke test via the configured executor.
|
|
886
|
+
#
|
|
887
|
+
# @param timeout [Integer, nil] timeout override in seconds
|
|
888
|
+
# @param provider_runtime [ProviderRuntime, Hash, nil] runtime overrides
|
|
889
|
+
# @return [Hash] normalized smoke-test result
|
|
890
|
+
def smoke_test(timeout: nil, provider_runtime: nil)
|
|
891
|
+
contract = smoke_test_contract
|
|
892
|
+
raise NotImplementedError, "#{self.class} does not implement #smoke_test_contract" unless contract
|
|
893
|
+
|
|
894
|
+
prompt = contract[:prompt]
|
|
895
|
+
if !prompt.is_a?(String) || prompt.strip.empty?
|
|
896
|
+
raise ConfigurationError, "#{self.class}.smoke_test_contract must define a non-empty :prompt"
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
response = send_message(
|
|
900
|
+
prompt: prompt,
|
|
901
|
+
timeout: timeout || contract[:timeout],
|
|
902
|
+
provider_runtime: provider_runtime
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
output = response.output.to_s.strip
|
|
906
|
+
expected_output = contract[:expected_output]&.strip
|
|
907
|
+
success = response.success? && (!contract.fetch(:require_output, true) || !output.empty?)
|
|
908
|
+
success &&= expected_output.nil? || output == expected_output
|
|
909
|
+
|
|
910
|
+
if success
|
|
911
|
+
return {
|
|
912
|
+
ok: true,
|
|
913
|
+
status: "ok",
|
|
914
|
+
message: contract[:success_message] || "Smoke test passed",
|
|
915
|
+
error_category: nil,
|
|
916
|
+
output: output,
|
|
917
|
+
exit_code: response.exit_code
|
|
918
|
+
}
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
message = response.error.to_s.strip
|
|
922
|
+
message = output if message.empty?
|
|
923
|
+
message = "Smoke test failed with exit code #{response.exit_code}" if message.empty?
|
|
924
|
+
|
|
925
|
+
{
|
|
926
|
+
ok: false,
|
|
927
|
+
status: "error",
|
|
928
|
+
message: message,
|
|
929
|
+
error_category: classify_smoke_test_message(message),
|
|
930
|
+
output: output,
|
|
931
|
+
exit_code: response.exit_code
|
|
932
|
+
}
|
|
933
|
+
rescue TimeoutError => e
|
|
934
|
+
failure_smoke_test_result(e.message, :timeout)
|
|
935
|
+
rescue AuthenticationError => e
|
|
936
|
+
failure_smoke_test_result(e.message, :auth_expired)
|
|
937
|
+
rescue RateLimitError => e
|
|
938
|
+
failure_smoke_test_result(e.message, :rate_limited)
|
|
939
|
+
rescue ProviderError => e
|
|
940
|
+
failure_smoke_test_result(e.message, classify_smoke_test_message(e.message))
|
|
941
|
+
end
|
|
942
|
+
|
|
243
943
|
# Execution semantics for this provider
|
|
244
944
|
#
|
|
245
945
|
# Returns a hash describing provider-specific execution behavior so
|
|
@@ -271,6 +971,23 @@ module AgentHarness
|
|
|
271
971
|
def parse_rate_limit_reset(output)
|
|
272
972
|
nil
|
|
273
973
|
end
|
|
974
|
+
|
|
975
|
+
private
|
|
976
|
+
|
|
977
|
+
def classify_smoke_test_message(message)
|
|
978
|
+
ErrorTaxonomy.classify(StandardError.new(message.to_s), error_patterns)
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def failure_smoke_test_result(message, error_category)
|
|
982
|
+
{
|
|
983
|
+
ok: false,
|
|
984
|
+
status: "error",
|
|
985
|
+
message: message,
|
|
986
|
+
error_category: error_category,
|
|
987
|
+
output: nil,
|
|
988
|
+
exit_code: nil
|
|
989
|
+
}
|
|
990
|
+
end
|
|
274
991
|
end
|
|
275
992
|
end
|
|
276
993
|
end
|