agent-harness 0.5.6 → 0.5.7
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 +16 -0
- data/README.md +76 -1
- 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 +21 -13
- data/lib/agent_harness/provider_health_check.rb +216 -58
- data/lib/agent_harness/provider_runtime.rb +20 -3
- data/lib/agent_harness/providers/adapter.rb +136 -0
- data/lib/agent_harness/providers/aider.rb +4 -0
- data/lib/agent_harness/providers/anthropic.rb +4 -0
- data/lib/agent_harness/providers/base.rb +46 -10
- data/lib/agent_harness/providers/codex.rb +53 -9
- data/lib/agent_harness/providers/cursor.rb +17 -1
- data/lib/agent_harness/providers/gemini.rb +34 -0
- data/lib/agent_harness/providers/github_copilot.rb +26 -6
- data/lib/agent_harness/providers/kilocode.rb +39 -0
- data/lib/agent_harness/providers/mistral_vibe.rb +4 -0
- data/lib/agent_harness/providers/opencode.rb +68 -1
- data/lib/agent_harness/providers/registry.rb +54 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +77 -6
- metadata +21 -1
|
@@ -25,14 +25,18 @@ module AgentHarness
|
|
|
25
25
|
#
|
|
26
26
|
# @param timeout [Integer] timeout in seconds for each check
|
|
27
27
|
# @return [Array<Hash>] health status for each provider
|
|
28
|
-
def check_all(timeout: configured_timeout)
|
|
28
|
+
def check_all(timeout: configured_timeout, executor: nil, provider_runtime: nil)
|
|
29
|
+
raise ArgumentError, "provider_runtime is only supported for single-provider health checks" unless provider_runtime.nil?
|
|
30
|
+
|
|
29
31
|
provider_names = if AgentHarness.configuration.providers.empty?
|
|
30
32
|
Providers::Registry.instance.all
|
|
31
33
|
else
|
|
32
34
|
enabled_provider_names
|
|
33
35
|
end
|
|
34
36
|
|
|
35
|
-
provider_names.map
|
|
37
|
+
provider_names.map do |name|
|
|
38
|
+
check(name, timeout: timeout, executor: executor, provider_runtime: provider_runtime)
|
|
39
|
+
end
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
# Check health of a single provider
|
|
@@ -40,32 +44,47 @@ module AgentHarness
|
|
|
40
44
|
# @param provider_name [Symbol, String] the provider name
|
|
41
45
|
# @param timeout [Integer] timeout in seconds
|
|
42
46
|
# @return [Hash] health status with :name, :status, :message, :latency_ms keys
|
|
43
|
-
def check(provider_name, timeout: configured_timeout)
|
|
47
|
+
def check(provider_name, timeout: configured_timeout, executor: nil, provider_runtime: nil)
|
|
44
48
|
name = normalize_name(provider_name)
|
|
45
49
|
start_time = monotonic_now
|
|
46
50
|
timeout = validate_timeout(timeout)
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
# Honor the provider smoke-test contract timeout when it exceeds
|
|
53
|
+
# the health-check timeout, so real CLI round trips are not
|
|
54
|
+
# falsely reported as timeouts.
|
|
55
|
+
outer_timeout = effective_check_timeout(name, timeout)
|
|
56
|
+
|
|
57
|
+
Timeout.timeout(outer_timeout) do
|
|
58
|
+
perform_check(
|
|
59
|
+
name,
|
|
60
|
+
start_time,
|
|
61
|
+
timeout: timeout,
|
|
62
|
+
executor: executor,
|
|
63
|
+
provider_runtime: provider_runtime
|
|
64
|
+
)
|
|
50
65
|
end
|
|
51
66
|
rescue Timeout::Error
|
|
52
67
|
build_result(
|
|
53
68
|
name: name,
|
|
54
69
|
status: "error",
|
|
55
|
-
message: "Health check timed out after #{timeout}s",
|
|
56
|
-
start_time: start_time || monotonic_now
|
|
70
|
+
message: "Health check timed out after #{outer_timeout || timeout}s",
|
|
71
|
+
start_time: start_time || monotonic_now,
|
|
72
|
+
error_category: :timeout,
|
|
73
|
+
check: :timeout
|
|
57
74
|
)
|
|
58
|
-
rescue NotImplementedError => e
|
|
75
|
+
rescue NotImplementedError, ConfigurationError => e
|
|
59
76
|
# NotImplementedError inherits from ScriptError, not StandardError,
|
|
60
77
|
# so it must be rescued explicitly. Its messages are safe internal
|
|
61
|
-
# setup errors (e.g., missing provider methods
|
|
62
|
-
# diagnose configuration problems.
|
|
78
|
+
# setup errors (e.g., missing provider methods or malformed provider
|
|
79
|
+
# contracts) that help users diagnose configuration problems.
|
|
63
80
|
AgentHarness.logger&.error("ProviderHealthCheck error for #{name}: #{e.class}")
|
|
64
81
|
build_result(
|
|
65
82
|
name: name,
|
|
66
83
|
status: "error",
|
|
67
84
|
message: "Health check failed: #{e.class}: #{e.message}",
|
|
68
|
-
start_time: start_time || monotonic_now
|
|
85
|
+
start_time: start_time || monotonic_now,
|
|
86
|
+
error_category: :configuration,
|
|
87
|
+
check: :provider_health
|
|
69
88
|
)
|
|
70
89
|
rescue => e
|
|
71
90
|
# Return a generic message to avoid leaking sensitive details
|
|
@@ -76,7 +95,9 @@ module AgentHarness
|
|
|
76
95
|
name: name,
|
|
77
96
|
status: "error",
|
|
78
97
|
message: "Health check failed: #{e.class}",
|
|
79
|
-
start_time: start_time || monotonic_now
|
|
98
|
+
start_time: start_time || monotonic_now,
|
|
99
|
+
error_category: :unknown,
|
|
100
|
+
check: :provider_health
|
|
80
101
|
)
|
|
81
102
|
end
|
|
82
103
|
|
|
@@ -148,7 +169,7 @@ module AgentHarness
|
|
|
148
169
|
:unknown
|
|
149
170
|
end
|
|
150
171
|
|
|
151
|
-
def perform_check(provider_name, start_time)
|
|
172
|
+
def perform_check(provider_name, start_time, timeout:, executor:, provider_runtime:)
|
|
152
173
|
# Step 1: Check provider is registered
|
|
153
174
|
registry = Providers::Registry.instance
|
|
154
175
|
unless registry.registered?(provider_name)
|
|
@@ -156,53 +177,83 @@ module AgentHarness
|
|
|
156
177
|
name: provider_name,
|
|
157
178
|
status: "error",
|
|
158
179
|
message: "Provider not registered",
|
|
159
|
-
start_time: start_time
|
|
180
|
+
start_time: start_time,
|
|
181
|
+
error_category: :installation,
|
|
182
|
+
check: :registration
|
|
160
183
|
)
|
|
161
184
|
end
|
|
162
185
|
|
|
163
|
-
# Step 2: Check CLI availability
|
|
164
186
|
klass = registry.get(provider_name)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
name: provider_name,
|
|
168
|
-
status: "error",
|
|
169
|
-
message: "CLI '#{klass.binary_name}' not found in PATH",
|
|
170
|
-
start_time: start_time
|
|
171
|
-
)
|
|
172
|
-
end
|
|
187
|
+
provider_instance = build_provider(provider_name, klass, executor: executor)
|
|
188
|
+
host_preflight_allowed = host_preflight_allowed?(executor: executor, provider_runtime: provider_runtime)
|
|
173
189
|
|
|
174
|
-
# Step 3: Check authentication
|
|
175
|
-
# Treat "not implemented" auth status as degraded rather than error,
|
|
176
|
-
# since most built-in providers don't implement auth_status hooks.
|
|
177
|
-
# In either case, continue to steps 4/5 so health and config issues
|
|
178
|
-
# are still surfaced for providers that lack an auth_status hook.
|
|
179
|
-
auth = Authentication.auth_status(provider_name)
|
|
180
190
|
auth_degraded = false
|
|
181
|
-
|
|
182
|
-
|
|
191
|
+
if host_preflight_allowed
|
|
192
|
+
# Step 2a: Honor the provider's `.available?` contract when running
|
|
193
|
+
# against the default host executor. Custom providers may enforce
|
|
194
|
+
# version or feature checks beyond simple PATH presence, so this
|
|
195
|
+
# catches cases where the binary exists but the provider considers
|
|
196
|
+
# itself unavailable. We skip this when a custom executor is
|
|
197
|
+
# supplied because `.available?` always queries the global
|
|
198
|
+
# executor, which may not reflect the caller's execution context.
|
|
199
|
+
if executor.nil? && !klass.available?
|
|
183
200
|
return build_result(
|
|
184
201
|
name: provider_name,
|
|
185
202
|
status: "error",
|
|
186
|
-
message:
|
|
187
|
-
start_time: start_time
|
|
203
|
+
message: "Provider '#{klass.binary_name}' is not available (#{klass}.available? returned false)",
|
|
204
|
+
start_time: start_time,
|
|
205
|
+
error_category: :installation,
|
|
206
|
+
check: :availability
|
|
188
207
|
)
|
|
189
208
|
end
|
|
190
|
-
auth_degraded = true
|
|
191
|
-
end
|
|
192
209
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
# Step 2b: Verify the binary is findable by the effective executor.
|
|
211
|
+
unless provider_instance.executor.which(klass.binary_name)
|
|
212
|
+
return build_result(
|
|
213
|
+
name: provider_name,
|
|
214
|
+
status: "error",
|
|
215
|
+
message: "CLI '#{klass.binary_name}' not found in PATH",
|
|
216
|
+
start_time: start_time,
|
|
217
|
+
error_category: :installation,
|
|
218
|
+
check: :availability
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Step 3: Check authentication
|
|
223
|
+
# Treat "not implemented" auth status as degraded rather than error,
|
|
224
|
+
# since most built-in providers don't implement auth_status hooks.
|
|
225
|
+
# In either case, continue to steps 4/5 so health and config issues
|
|
226
|
+
# are still surfaced for providers that lack an auth_status hook.
|
|
227
|
+
auth = Authentication.auth_status(provider_name)
|
|
228
|
+
unless auth[:valid]
|
|
229
|
+
unless auth_not_implemented?(auth)
|
|
230
|
+
return build_result(
|
|
231
|
+
name: provider_name,
|
|
232
|
+
status: "error",
|
|
233
|
+
message: auth[:error] || "Authentication failed",
|
|
234
|
+
start_time: start_time,
|
|
235
|
+
error_category: :authentication,
|
|
236
|
+
check: :authentication
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
auth_degraded = true
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Step 4: Check provider-level health (e.g., endpoint reachability)
|
|
243
|
+
# The Adapter default always returns {healthy: true}, so providers
|
|
244
|
+
# that haven't implemented a real health check are reported as ok
|
|
245
|
+
# with a note that the check is not implemented.
|
|
246
|
+
health = provider_instance.health_status
|
|
247
|
+
unless health[:healthy]
|
|
248
|
+
return build_result(
|
|
249
|
+
name: provider_name,
|
|
250
|
+
status: "degraded",
|
|
251
|
+
message: health[:message] || "Provider health check failed",
|
|
252
|
+
start_time: start_time,
|
|
253
|
+
error_category: :transient,
|
|
254
|
+
check: :provider_health
|
|
255
|
+
)
|
|
256
|
+
end
|
|
206
257
|
end
|
|
207
258
|
|
|
208
259
|
# Step 5: Validate provider config
|
|
@@ -216,7 +267,52 @@ module AgentHarness
|
|
|
216
267
|
name: provider_name,
|
|
217
268
|
status: "degraded",
|
|
218
269
|
message: "Configuration issues: #{errors_msg}",
|
|
219
|
-
start_time: start_time
|
|
270
|
+
start_time: start_time,
|
|
271
|
+
error_category: :configuration,
|
|
272
|
+
check: :configuration
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
smoke_contract = provider_instance.smoke_test_contract
|
|
277
|
+
# Explicitly handle missing smoke-test contract when no custom smoke_test implementation
|
|
278
|
+
if smoke_contract.nil? && !provider_overrides_method?(provider_instance, :smoke_test)
|
|
279
|
+
message = if host_preflight_allowed && auth_degraded
|
|
280
|
+
"Auth status check not implemented; health and config checks passed (smoke test unavailable)"
|
|
281
|
+
elsif host_preflight_allowed && (provider_overrides_method?(provider_instance, :health_status) ||
|
|
282
|
+
provider_overrides_method?(provider_instance, :validate_config))
|
|
283
|
+
"Health and config checks passed (smoke test unavailable)"
|
|
284
|
+
elsif host_preflight_allowed
|
|
285
|
+
"Registered and authenticated; health/config checks use defaults and smoke test is unavailable"
|
|
286
|
+
elsif provider_overrides_method?(provider_instance, :validate_config)
|
|
287
|
+
"Configuration checks passed, but smoke test is unavailable for the supplied execution context"
|
|
288
|
+
else
|
|
289
|
+
"Smoke test is unavailable for the supplied execution context"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
return build_result(
|
|
293
|
+
name: provider_name,
|
|
294
|
+
status: "degraded",
|
|
295
|
+
message: message,
|
|
296
|
+
start_time: start_time,
|
|
297
|
+
error_category: :configuration,
|
|
298
|
+
check: :smoke_test
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# When a contract exists, pass nil so the adapter falls through to
|
|
303
|
+
# contract[:timeout]. When the provider overrides #smoke_test without
|
|
304
|
+
# publishing a contract, forward the validated health-check timeout so
|
|
305
|
+
# the override can honour it instead of running without any limit.
|
|
306
|
+
smoke_timeout = smoke_contract ? nil : timeout
|
|
307
|
+
smoke = provider_instance.smoke_test(timeout: smoke_timeout, provider_runtime: provider_runtime)
|
|
308
|
+
unless smoke[:ok]
|
|
309
|
+
return build_result(
|
|
310
|
+
name: provider_name,
|
|
311
|
+
status: smoke[:status] || "error",
|
|
312
|
+
message: smoke[:message] || "Smoke test failed",
|
|
313
|
+
start_time: start_time,
|
|
314
|
+
error_category: normalize_smoke_error_category(smoke[:error_category], smoke[:message]),
|
|
315
|
+
check: :smoke_test
|
|
220
316
|
)
|
|
221
317
|
end
|
|
222
318
|
|
|
@@ -225,23 +321,30 @@ module AgentHarness
|
|
|
225
321
|
return build_result(
|
|
226
322
|
name: provider_name,
|
|
227
323
|
status: "degraded",
|
|
228
|
-
message: "Auth status check not implemented; health and
|
|
229
|
-
start_time: start_time
|
|
324
|
+
message: "Auth status check not implemented; health, config, and smoke tests passed",
|
|
325
|
+
start_time: start_time,
|
|
326
|
+
error_category: :authentication,
|
|
327
|
+
check: :authentication
|
|
230
328
|
)
|
|
231
329
|
end
|
|
232
330
|
|
|
233
|
-
message = if provider_overrides_method?(provider_instance, :
|
|
331
|
+
message = if !host_preflight_allowed && provider_overrides_method?(provider_instance, :validate_config)
|
|
332
|
+
"Configuration and smoke test passed using the supplied execution context"
|
|
333
|
+
elsif !host_preflight_allowed
|
|
334
|
+
"Smoke test passed using the supplied execution context"
|
|
335
|
+
elsif provider_overrides_method?(provider_instance, :health_status) ||
|
|
234
336
|
provider_overrides_method?(provider_instance, :validate_config)
|
|
235
337
|
"All checks passed"
|
|
236
338
|
else
|
|
237
|
-
"Registered and
|
|
339
|
+
"Registered, authenticated, and smoke test passed (health/config checks use defaults)"
|
|
238
340
|
end
|
|
239
341
|
|
|
240
342
|
build_result(
|
|
241
343
|
name: provider_name,
|
|
242
344
|
status: "ok",
|
|
243
345
|
message: message,
|
|
244
|
-
start_time: start_time
|
|
346
|
+
start_time: start_time,
|
|
347
|
+
check: :smoke_test
|
|
245
348
|
)
|
|
246
349
|
end
|
|
247
350
|
|
|
@@ -258,25 +361,80 @@ module AgentHarness
|
|
|
258
361
|
error.include?("not implemented")
|
|
259
362
|
end
|
|
260
363
|
|
|
364
|
+
def host_preflight_allowed?(executor:, provider_runtime: nil)
|
|
365
|
+
effective_executor = executor || AgentHarness.configuration.command_executor
|
|
366
|
+
# Skip host preflight only when provider runtime has environment/config overrides
|
|
367
|
+
# that could conflict with host-level checks (env, base_url, api_provider, unset_env)
|
|
368
|
+
if provider_runtime
|
|
369
|
+
runtime = ProviderRuntime.wrap(provider_runtime)
|
|
370
|
+
return false if runtime && (!runtime.env.empty? || !runtime.unset_env.empty? || runtime.base_url || runtime.api_provider)
|
|
371
|
+
end
|
|
372
|
+
effective_executor.is_a?(CommandExecutor) && !effective_executor.is_a?(DockerCommandExecutor)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def effective_check_timeout(provider_name, base_timeout)
|
|
376
|
+
registry = Providers::Registry.instance
|
|
377
|
+
return base_timeout unless registry.registered?(provider_name)
|
|
378
|
+
|
|
379
|
+
contract = registry.smoke_test_contract(provider_name)
|
|
380
|
+
contract_timeout = contract&.dig(:timeout)
|
|
381
|
+
return base_timeout unless contract_timeout.is_a?(Numeric) && contract_timeout.positive?
|
|
382
|
+
|
|
383
|
+
[base_timeout, contract_timeout].max
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def normalize_smoke_error_category(category, message)
|
|
387
|
+
normalized = if installation_failure_message?(message)
|
|
388
|
+
:installation
|
|
389
|
+
else
|
|
390
|
+
category || ErrorTaxonomy.classify_message(message)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
case normalized&.to_sym
|
|
394
|
+
when :installation
|
|
395
|
+
:installation
|
|
396
|
+
when :auth_expired, :authentication
|
|
397
|
+
:authentication
|
|
398
|
+
when :rate_limited, :rate_limit
|
|
399
|
+
:rate_limit
|
|
400
|
+
when :quota_exceeded, :quota
|
|
401
|
+
:quota
|
|
402
|
+
when :timeout
|
|
403
|
+
:timeout
|
|
404
|
+
when :transient
|
|
405
|
+
:transient
|
|
406
|
+
when :sandbox_failure, :configuration, :permanent
|
|
407
|
+
:configuration
|
|
408
|
+
else
|
|
409
|
+
:unknown
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def installation_failure_message?(message)
|
|
414
|
+
message.to_s.match?(/(not found in PATH|command not found|No such file or directory|is not installed)/i)
|
|
415
|
+
end
|
|
416
|
+
|
|
261
417
|
def provider_overrides_method?(provider_instance, method_name)
|
|
262
418
|
provider_instance.method(method_name).owner != Providers::Adapter
|
|
263
419
|
end
|
|
264
420
|
|
|
265
|
-
def build_result(name:, status:, message:, start_time:)
|
|
421
|
+
def build_result(name:, status:, message:, start_time:, error_category: nil, check: nil)
|
|
266
422
|
latency = ((monotonic_now - start_time) * 1000).round
|
|
267
423
|
{
|
|
268
424
|
name: name,
|
|
269
425
|
status: status,
|
|
270
426
|
message: message,
|
|
271
|
-
latency_ms: latency
|
|
427
|
+
latency_ms: latency,
|
|
428
|
+
error_category: error_category,
|
|
429
|
+
check: check
|
|
272
430
|
}
|
|
273
431
|
end
|
|
274
432
|
|
|
275
|
-
def build_provider(provider_name, klass)
|
|
433
|
+
def build_provider(provider_name, klass, executor:)
|
|
276
434
|
config = AgentHarness.configuration.providers[provider_name]
|
|
277
435
|
klass.new(
|
|
278
436
|
config: config,
|
|
279
|
-
executor: AgentHarness.configuration.command_executor,
|
|
437
|
+
executor: executor || AgentHarness.configuration.command_executor,
|
|
280
438
|
logger: AgentHarness.logger
|
|
281
439
|
)
|
|
282
440
|
end
|
|
@@ -25,15 +25,16 @@ module AgentHarness
|
|
|
25
25
|
# }
|
|
26
26
|
# )
|
|
27
27
|
class ProviderRuntime
|
|
28
|
-
attr_reader :model, :base_url, :api_provider, :env, :flags, :metadata
|
|
28
|
+
attr_reader :model, :base_url, :api_provider, :env, :flags, :metadata, :unset_env
|
|
29
29
|
|
|
30
30
|
# @param model [String, nil] model identifier override
|
|
31
31
|
# @param base_url [String, nil] upstream API base URL override
|
|
32
32
|
# @param api_provider [String, nil] API-compatible backend name
|
|
33
33
|
# @param env [Hash<String,String>] extra environment variables for the subprocess
|
|
34
34
|
# @param flags [Array<String>] extra CLI flags to append
|
|
35
|
+
# @param unset_env [Array<String>] environment variable names to remove from inherited env
|
|
35
36
|
# @param metadata [Hash] arbitrary provider-specific data
|
|
36
|
-
def initialize(model: nil, base_url: nil, api_provider: nil, env: {}, flags: [], metadata: {})
|
|
37
|
+
def initialize(model: nil, base_url: nil, api_provider: nil, env: {}, flags: [], unset_env: [], metadata: {})
|
|
37
38
|
@model = model
|
|
38
39
|
@base_url = base_url
|
|
39
40
|
@api_provider = api_provider
|
|
@@ -70,6 +71,21 @@ module AgentHarness
|
|
|
70
71
|
end
|
|
71
72
|
@metadata = metadata_hash.dup.freeze
|
|
72
73
|
|
|
74
|
+
# Unset environment variables for the request. These are variable names that
|
|
75
|
+
# should be removed from the inherited environment before the provider
|
|
76
|
+
# command runs.
|
|
77
|
+
unset_array = unset_env || []
|
|
78
|
+
unless unset_array.is_a?(Array)
|
|
79
|
+
raise ArgumentError, "unset_env must be an Array (got #{unset_array.class})"
|
|
80
|
+
end
|
|
81
|
+
normalized_unset_env = unset_array.map.with_index do |key, index|
|
|
82
|
+
key.to_s
|
|
83
|
+
rescue NoMethodError
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"unset_env must contain values convertible to String; invalid element at index #{index}: #{key.inspect} (#{key.class})"
|
|
86
|
+
end
|
|
87
|
+
@unset_env = normalized_unset_env.freeze
|
|
88
|
+
|
|
73
89
|
freeze
|
|
74
90
|
end
|
|
75
91
|
|
|
@@ -86,6 +102,7 @@ module AgentHarness
|
|
|
86
102
|
api_provider: hash[:api_provider] || hash["api_provider"],
|
|
87
103
|
env: hash[:env] || hash["env"] || {},
|
|
88
104
|
flags: hash[:flags] || hash["flags"] || [],
|
|
105
|
+
unset_env: hash[:unset_env] || hash["unset_env"] || [],
|
|
89
106
|
metadata: hash[:metadata] || hash["metadata"] || {}
|
|
90
107
|
)
|
|
91
108
|
end
|
|
@@ -109,7 +126,7 @@ module AgentHarness
|
|
|
109
126
|
# @return [Boolean]
|
|
110
127
|
def empty?
|
|
111
128
|
model.nil? && base_url.nil? && api_provider.nil? &&
|
|
112
|
-
env.empty? && flags.empty? && metadata.empty?
|
|
129
|
+
env.empty? && flags.empty? && metadata.empty? && unset_env.empty?
|
|
113
130
|
end
|
|
114
131
|
end
|
|
115
132
|
end
|
|
@@ -43,6 +43,17 @@ module AgentHarness
|
|
|
43
43
|
raise NotImplementedError, "#{self} must implement .binary_name"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Installation contract for the provider CLI.
|
|
47
|
+
#
|
|
48
|
+
# Downstream applications can use this metadata to install a provider's
|
|
49
|
+
# supported CLI without hardcoding package names, install flags, or
|
|
50
|
+
# version pins outside AgentHarness.
|
|
51
|
+
#
|
|
52
|
+
# @return [Hash, nil] installation metadata or nil when not provided
|
|
53
|
+
def install_contract(version: nil)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
46
57
|
# Required domains for firewall configuration
|
|
47
58
|
#
|
|
48
59
|
# @return [Hash] with :domains and :ip_ranges arrays
|
|
@@ -63,6 +74,49 @@ module AgentHarness
|
|
|
63
74
|
def discover_models
|
|
64
75
|
[]
|
|
65
76
|
end
|
|
77
|
+
|
|
78
|
+
# Installation contract for this provider's CLI.
|
|
79
|
+
#
|
|
80
|
+
# Downstream apps can use this metadata to provision the provider CLI
|
|
81
|
+
# without hardcoding package names, versions, or binary expectations
|
|
82
|
+
# outside agent-harness.
|
|
83
|
+
#
|
|
84
|
+
# @return [Hash, nil] install metadata, or nil when no first-class
|
|
85
|
+
# installation contract is defined for the provider
|
|
86
|
+
def installation_contract(**options)
|
|
87
|
+
return install_contract unless options.key?(:version)
|
|
88
|
+
|
|
89
|
+
install_contract(version: options[:version])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Build the install command from the provider installation contract.
|
|
93
|
+
#
|
|
94
|
+
# @param version [String, nil] optional explicit version override
|
|
95
|
+
# @return [Array<String>, nil] install command argv or nil when the
|
|
96
|
+
# provider has no install contract
|
|
97
|
+
def install_command(version: nil)
|
|
98
|
+
contract = installation_contract
|
|
99
|
+
return nil unless contract
|
|
100
|
+
|
|
101
|
+
return contract[:install_command] unless version
|
|
102
|
+
|
|
103
|
+
package_name = contract[:package_name]
|
|
104
|
+
unless package_name
|
|
105
|
+
raise ArgumentError, "installation_contract must define :package_name when overriding version"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Array(contract[:install_command_prefix]) + ["#{package_name}@#{version}"]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Canonical smoke-test contract for this provider.
|
|
112
|
+
#
|
|
113
|
+
# CLI-backed providers should expose a minimal real-execution prompt so
|
|
114
|
+
# downstream apps can reuse a stable provider-owned health check.
|
|
115
|
+
#
|
|
116
|
+
# @return [Hash, nil] smoke-test metadata or nil when not provided
|
|
117
|
+
def smoke_test_contract
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
66
120
|
end
|
|
67
121
|
|
|
68
122
|
# Instance methods
|
|
@@ -240,6 +294,71 @@ module AgentHarness
|
|
|
240
294
|
{healthy: true, message: "OK"}
|
|
241
295
|
end
|
|
242
296
|
|
|
297
|
+
# Canonical smoke-test contract for this provider instance.
|
|
298
|
+
#
|
|
299
|
+
# @return [Hash, nil] smoke-test metadata
|
|
300
|
+
def smoke_test_contract
|
|
301
|
+
self.class.smoke_test_contract if self.class.respond_to?(:smoke_test_contract)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Execute a minimal provider-owned smoke test via the configured executor.
|
|
305
|
+
#
|
|
306
|
+
# @param timeout [Integer, nil] timeout override in seconds
|
|
307
|
+
# @param provider_runtime [ProviderRuntime, Hash, nil] runtime overrides
|
|
308
|
+
# @return [Hash] normalized smoke-test result
|
|
309
|
+
def smoke_test(timeout: nil, provider_runtime: nil)
|
|
310
|
+
contract = smoke_test_contract
|
|
311
|
+
raise NotImplementedError, "#{self.class} does not implement #smoke_test_contract" unless contract
|
|
312
|
+
|
|
313
|
+
prompt = contract[:prompt]
|
|
314
|
+
if !prompt.is_a?(String) || prompt.strip.empty?
|
|
315
|
+
raise ConfigurationError, "#{self.class}.smoke_test_contract must define a non-empty :prompt"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
response = send_message(
|
|
319
|
+
prompt: prompt,
|
|
320
|
+
timeout: timeout || contract[:timeout],
|
|
321
|
+
provider_runtime: provider_runtime
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
output = response.output.to_s.strip
|
|
325
|
+
expected_output = contract[:expected_output]&.strip
|
|
326
|
+
success = response.success? && (!contract.fetch(:require_output, true) || !output.empty?)
|
|
327
|
+
success &&= expected_output.nil? || output == expected_output
|
|
328
|
+
|
|
329
|
+
if success
|
|
330
|
+
return {
|
|
331
|
+
ok: true,
|
|
332
|
+
status: "ok",
|
|
333
|
+
message: contract[:success_message] || "Smoke test passed",
|
|
334
|
+
error_category: nil,
|
|
335
|
+
output: output,
|
|
336
|
+
exit_code: response.exit_code
|
|
337
|
+
}
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
message = response.error.to_s.strip
|
|
341
|
+
message = output if message.empty?
|
|
342
|
+
message = "Smoke test failed with exit code #{response.exit_code}" if message.empty?
|
|
343
|
+
|
|
344
|
+
{
|
|
345
|
+
ok: false,
|
|
346
|
+
status: "error",
|
|
347
|
+
message: message,
|
|
348
|
+
error_category: classify_smoke_test_message(message),
|
|
349
|
+
output: output,
|
|
350
|
+
exit_code: response.exit_code
|
|
351
|
+
}
|
|
352
|
+
rescue TimeoutError => e
|
|
353
|
+
failure_smoke_test_result(e.message, :timeout)
|
|
354
|
+
rescue AuthenticationError => e
|
|
355
|
+
failure_smoke_test_result(e.message, :auth_expired)
|
|
356
|
+
rescue RateLimitError => e
|
|
357
|
+
failure_smoke_test_result(e.message, :rate_limited)
|
|
358
|
+
rescue ProviderError => e
|
|
359
|
+
failure_smoke_test_result(e.message, classify_smoke_test_message(e.message))
|
|
360
|
+
end
|
|
361
|
+
|
|
243
362
|
# Execution semantics for this provider
|
|
244
363
|
#
|
|
245
364
|
# Returns a hash describing provider-specific execution behavior so
|
|
@@ -271,6 +390,23 @@ module AgentHarness
|
|
|
271
390
|
def parse_rate_limit_reset(output)
|
|
272
391
|
nil
|
|
273
392
|
end
|
|
393
|
+
|
|
394
|
+
private
|
|
395
|
+
|
|
396
|
+
def classify_smoke_test_message(message)
|
|
397
|
+
ErrorTaxonomy.classify(StandardError.new(message.to_s), error_patterns)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def failure_smoke_test_result(message, error_category)
|
|
401
|
+
{
|
|
402
|
+
ok: false,
|
|
403
|
+
status: "error",
|
|
404
|
+
message: message,
|
|
405
|
+
error_category: error_category,
|
|
406
|
+
output: nil,
|
|
407
|
+
exit_code: nil
|
|
408
|
+
}
|
|
409
|
+
end
|
|
274
410
|
end
|
|
275
411
|
end
|
|
276
412
|
end
|