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
|
@@ -22,16 +22,18 @@ module AgentHarness
|
|
|
22
22
|
# system("which my-cli > /dev/null 2>&1")
|
|
23
23
|
# end
|
|
24
24
|
# end
|
|
25
|
-
#
|
|
26
|
-
# protected
|
|
27
|
-
#
|
|
28
|
-
# def build_command(prompt, options)
|
|
29
|
-
# [self.class.binary_name, "--prompt", prompt]
|
|
30
|
-
# end
|
|
31
25
|
# end
|
|
32
26
|
class Base
|
|
33
27
|
include Adapter
|
|
34
28
|
|
|
29
|
+
DEFAULT_SMOKE_TEST_CONTRACT = {
|
|
30
|
+
prompt: "Reply with exactly OK.",
|
|
31
|
+
expected_output: "OK",
|
|
32
|
+
timeout: 30,
|
|
33
|
+
require_output: true,
|
|
34
|
+
success_message: "Smoke test passed"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
35
37
|
# Common error patterns shared across providers that use standard
|
|
36
38
|
# HTTP-style error responses. Providers with unique patterns (e.g.
|
|
37
39
|
# Anthropic, GitHub Copilot) override error_patterns entirely.
|
|
@@ -63,6 +65,12 @@ module AgentHarness
|
|
|
63
65
|
attr_reader :config, :logger
|
|
64
66
|
attr_accessor :executor
|
|
65
67
|
|
|
68
|
+
class << self
|
|
69
|
+
def smoke_test_contract
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
66
74
|
# Initialize the provider
|
|
67
75
|
#
|
|
68
76
|
# @param config [ProviderConfig, nil] provider configuration
|
|
@@ -109,7 +117,12 @@ module AgentHarness
|
|
|
109
117
|
|
|
110
118
|
# Execute command
|
|
111
119
|
start_time = Time.now
|
|
112
|
-
result = execute_with_timeout(
|
|
120
|
+
result = execute_with_timeout(
|
|
121
|
+
command,
|
|
122
|
+
timeout: timeout,
|
|
123
|
+
env: build_env(options),
|
|
124
|
+
**command_execution_options(options)
|
|
125
|
+
)
|
|
113
126
|
duration = Time.now - start_time
|
|
114
127
|
|
|
115
128
|
# Parse response
|
|
@@ -190,7 +203,11 @@ module AgentHarness
|
|
|
190
203
|
runtime = options[:provider_runtime]
|
|
191
204
|
return {} unless runtime
|
|
192
205
|
|
|
193
|
-
|
|
206
|
+
# Return overrides only. Ruby subprocess spawning treats nil values as
|
|
207
|
+
# explicit unsets in the child process, while omitted keys are inherited.
|
|
208
|
+
env = runtime.env.dup
|
|
209
|
+
runtime.unset_env.each { |key| env[key] = nil }
|
|
210
|
+
env
|
|
194
211
|
end
|
|
195
212
|
|
|
196
213
|
# Parse CLI output into Response - override in subclasses
|
|
@@ -279,8 +296,21 @@ module AgentHarness
|
|
|
279
296
|
options.merge(mcp_servers: normalized)
|
|
280
297
|
end
|
|
281
298
|
|
|
282
|
-
def
|
|
283
|
-
|
|
299
|
+
def command_execution_options(options)
|
|
300
|
+
execution_options = {
|
|
301
|
+
idle_timeout: options[:idle_timeout],
|
|
302
|
+
on_stdout_chunk: options[:on_stdout_chunk],
|
|
303
|
+
on_stderr_chunk: options[:on_stderr_chunk],
|
|
304
|
+
on_heartbeat: options[:on_heartbeat],
|
|
305
|
+
observer: options[:execution_observer] || options[:observer]
|
|
306
|
+
}.reject { |_, value| value.nil? }
|
|
307
|
+
|
|
308
|
+
execution_options[:heartbeat_interval] = options[:heartbeat_interval] if options.key?(:heartbeat_interval)
|
|
309
|
+
execution_options
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def execute_with_timeout(command, timeout:, env:, stdin_data: nil, **execution_options)
|
|
313
|
+
@executor.execute(command, timeout: timeout, env: env, stdin_data: stdin_data, **execution_options)
|
|
284
314
|
end
|
|
285
315
|
|
|
286
316
|
def track_tokens(response)
|
|
@@ -319,7 +349,13 @@ module AgentHarness
|
|
|
319
349
|
original_error: original_error
|
|
320
350
|
)
|
|
321
351
|
when :timeout
|
|
352
|
+
return original_error if original_error.is_a?(TimeoutError)
|
|
353
|
+
|
|
322
354
|
TimeoutError.new(original_error.message, original_error: original_error)
|
|
355
|
+
when :idle_timeout
|
|
356
|
+
return original_error if original_error.is_a?(IdleTimeoutError)
|
|
357
|
+
|
|
358
|
+
IdleTimeoutError.new(original_error.message, original_error: original_error)
|
|
323
359
|
else
|
|
324
360
|
ProviderError.new(original_error.message, original_error: original_error)
|
|
325
361
|
end
|
|
@@ -8,6 +8,9 @@ module AgentHarness
|
|
|
8
8
|
#
|
|
9
9
|
# Provides integration with the OpenAI Codex CLI tool.
|
|
10
10
|
class Codex < Base
|
|
11
|
+
SUPPORTED_CLI_VERSION = "0.116.0"
|
|
12
|
+
SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 0.117.0").freeze
|
|
13
|
+
|
|
11
14
|
class << self
|
|
12
15
|
def provider_name
|
|
13
16
|
:codex
|
|
@@ -49,6 +52,37 @@ module AgentHarness
|
|
|
49
52
|
{name: "codex", family: "codex", tier: "standard", provider: "codex"}
|
|
50
53
|
]
|
|
51
54
|
end
|
|
55
|
+
|
|
56
|
+
def installation_contract
|
|
57
|
+
default_package = "@openai/codex@#{SUPPORTED_CLI_VERSION}".freeze
|
|
58
|
+
install_command_prefix = ["npm", "install", "-g", "--ignore-scripts"].freeze
|
|
59
|
+
install_command = (install_command_prefix + [default_package]).freeze
|
|
60
|
+
supported_versions = [SUPPORTED_CLI_VERSION].freeze
|
|
61
|
+
version_requirement = SUPPORTED_CLI_REQUIREMENT.requirements
|
|
62
|
+
.map { |op, ver| "#{op} #{ver}".freeze }
|
|
63
|
+
.freeze
|
|
64
|
+
|
|
65
|
+
contract = {
|
|
66
|
+
source: :npm,
|
|
67
|
+
package: default_package,
|
|
68
|
+
package_name: "@openai/codex",
|
|
69
|
+
version: SUPPORTED_CLI_VERSION,
|
|
70
|
+
version_requirement: version_requirement,
|
|
71
|
+
binary_name: binary_name,
|
|
72
|
+
install_command_prefix: install_command_prefix,
|
|
73
|
+
install_command: install_command,
|
|
74
|
+
supported_versions: supported_versions
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
contract.each_value do |value|
|
|
78
|
+
value.freeze if value.is_a?(String)
|
|
79
|
+
end
|
|
80
|
+
contract.freeze
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def smoke_test_contract
|
|
84
|
+
Base::DEFAULT_SMOKE_TEST_CONTRACT
|
|
85
|
+
end
|
|
52
86
|
end
|
|
53
87
|
|
|
54
88
|
def name
|
|
@@ -194,12 +228,16 @@ module AgentHarness
|
|
|
194
228
|
|
|
195
229
|
def build_command(prompt, options)
|
|
196
230
|
cmd = [self.class.binary_name, "exec"]
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
|
|
231
|
+
externally_sandboxed = externally_sandboxed?(options)
|
|
232
|
+
|
|
233
|
+
# When externally_sandboxed is set, use --dangerously-bypass-approvals-and-sandbox
|
|
234
|
+
# instead of --full-auto. In the Codex CLI, full_auto is checked first and
|
|
235
|
+
# selects workspace-write sandbox mode, which overrides the bypass flag.
|
|
236
|
+
# Passing both would leave the run in the wrong sandbox mode.
|
|
237
|
+
#
|
|
238
|
+
# When NOT externally sandboxed: use --full-auto for Docker containers
|
|
239
|
+
# (to skip nested sandboxing) or when dangerous_mode is explicitly requested.
|
|
240
|
+
if !externally_sandboxed && (sandboxed_environment? || options[:dangerous_mode])
|
|
203
241
|
cmd += dangerous_mode_flags
|
|
204
242
|
end
|
|
205
243
|
|
|
@@ -208,10 +246,13 @@ module AgentHarness
|
|
|
208
246
|
unless flags.is_a?(Array)
|
|
209
247
|
raise ArgumentError, "Codex configuration error: default_flags must be an array of strings"
|
|
210
248
|
end
|
|
249
|
+
# Strip --full-auto from defaults when externally sandboxed to avoid
|
|
250
|
+
# conflicting with --dangerously-bypass-approvals-and-sandbox.
|
|
251
|
+
flags -= dangerous_mode_flags if externally_sandboxed
|
|
211
252
|
cmd += flags if flags.any?
|
|
212
253
|
end
|
|
213
254
|
|
|
214
|
-
if externally_sandboxed
|
|
255
|
+
if externally_sandboxed
|
|
215
256
|
cmd += sandbox_bypass_flags
|
|
216
257
|
end
|
|
217
258
|
|
|
@@ -222,7 +263,10 @@ module AgentHarness
|
|
|
222
263
|
runtime = options[:provider_runtime]
|
|
223
264
|
if runtime
|
|
224
265
|
cmd += ["--model", runtime.model] if runtime.model
|
|
225
|
-
|
|
266
|
+
runtime_flags = runtime.flags
|
|
267
|
+
# Strip --full-auto from runtime flags when externally sandboxed.
|
|
268
|
+
runtime_flags -= dangerous_mode_flags if externally_sandboxed
|
|
269
|
+
cmd += runtime_flags unless runtime_flags.empty?
|
|
226
270
|
end
|
|
227
271
|
|
|
228
272
|
cmd << prompt
|
|
@@ -260,7 +304,7 @@ module AgentHarness
|
|
|
260
304
|
end
|
|
261
305
|
|
|
262
306
|
def sandbox_bypass_flags
|
|
263
|
-
["--sandbox"
|
|
307
|
+
["--dangerously-bypass-approvals-and-sandbox"]
|
|
264
308
|
end
|
|
265
309
|
|
|
266
310
|
def read_codex_credentials
|
|
@@ -83,6 +83,10 @@ module AgentHarness
|
|
|
83
83
|
def supports_model_family?(family_name)
|
|
84
84
|
family_name.match?(/^(claude|gpt|cursor)-/)
|
|
85
85
|
end
|
|
86
|
+
|
|
87
|
+
def smoke_test_contract
|
|
88
|
+
Base::DEFAULT_SMOKE_TEST_CONTRACT
|
|
89
|
+
end
|
|
86
90
|
end
|
|
87
91
|
|
|
88
92
|
def name
|
|
@@ -189,7 +193,13 @@ module AgentHarness
|
|
|
189
193
|
# Execute command with prompt on stdin
|
|
190
194
|
env = build_env(options)
|
|
191
195
|
start_time = Time.now
|
|
192
|
-
result =
|
|
196
|
+
result = execute_with_timeout(
|
|
197
|
+
command,
|
|
198
|
+
timeout: timeout,
|
|
199
|
+
env: env,
|
|
200
|
+
stdin_data: prompt,
|
|
201
|
+
**command_execution_options(options)
|
|
202
|
+
)
|
|
193
203
|
duration = Time.now - start_time
|
|
194
204
|
|
|
195
205
|
# Parse response
|
|
@@ -327,7 +337,13 @@ module AgentHarness
|
|
|
327
337
|
when :auth_expired
|
|
328
338
|
raise AuthenticationError.new(error.message, provider: self.class.provider_name, original_error: error)
|
|
329
339
|
when :timeout
|
|
340
|
+
raise error if error.is_a?(TimeoutError)
|
|
341
|
+
|
|
330
342
|
raise TimeoutError.new(error.message, original_error: error)
|
|
343
|
+
when :idle_timeout
|
|
344
|
+
raise error if error.is_a?(IdleTimeoutError)
|
|
345
|
+
|
|
346
|
+
raise IdleTimeoutError.new(error.message, original_error: error)
|
|
331
347
|
else
|
|
332
348
|
raise ProviderError.new(error.message, original_error: error)
|
|
333
349
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "rubygems/requirement"
|
|
4
5
|
require "time"
|
|
5
6
|
|
|
6
7
|
module AgentHarness
|
|
@@ -11,6 +12,9 @@ module AgentHarness
|
|
|
11
12
|
class Gemini < Base
|
|
12
13
|
# Model name pattern for Gemini models
|
|
13
14
|
MODEL_PATTERN = /^gemini-[\d.]+-(?:pro|flash|ultra)(?:-\d+)?$/i
|
|
15
|
+
CLI_PACKAGE = "@google/gemini-cli"
|
|
16
|
+
SUPPORTED_CLI_VERSION = "0.35.3"
|
|
17
|
+
SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new("= #{SUPPORTED_CLI_VERSION}").freeze
|
|
14
18
|
|
|
15
19
|
class << self
|
|
16
20
|
def provider_name
|
|
@@ -26,6 +30,32 @@ module AgentHarness
|
|
|
26
30
|
!!executor.which(binary_name)
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
def install_contract(version: SUPPORTED_CLI_VERSION)
|
|
34
|
+
parsed_version = begin
|
|
35
|
+
Gem::Version.new(version)
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
raise ArgumentError, "Unsupported Gemini CLI version #{version.inspect}. Supported requirement: #{SUPPORTED_CLI_REQUIREMENT}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
unless SUPPORTED_CLI_REQUIREMENT.satisfied_by?(parsed_version)
|
|
41
|
+
raise ArgumentError, "Unsupported Gemini CLI version #{version.inspect}. Supported requirement: #{SUPPORTED_CLI_REQUIREMENT}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
package_spec = "#{CLI_PACKAGE}@#{version}"
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
provider: provider_name,
|
|
48
|
+
source_type: :npm,
|
|
49
|
+
package_name: CLI_PACKAGE,
|
|
50
|
+
supported_version_requirement: SUPPORTED_CLI_REQUIREMENT,
|
|
51
|
+
default_version: SUPPORTED_CLI_VERSION,
|
|
52
|
+
resolved_version: version,
|
|
53
|
+
binary_name: binary_name,
|
|
54
|
+
install_command: ["npm", "install", "-g", "--ignore-scripts", package_spec],
|
|
55
|
+
install_command_string: "npm install -g --ignore-scripts #{package_spec}"
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
29
59
|
def firewall_requirements
|
|
30
60
|
{
|
|
31
61
|
domains: [
|
|
@@ -73,6 +103,10 @@ module AgentHarness
|
|
|
73
103
|
def supports_model_family?(family_name)
|
|
74
104
|
MODEL_PATTERN.match?(family_name) || family_name.start_with?("gemini-")
|
|
75
105
|
end
|
|
106
|
+
|
|
107
|
+
def smoke_test_contract
|
|
108
|
+
Base::DEFAULT_SMOKE_TEST_CONTRACT
|
|
109
|
+
end
|
|
76
110
|
end
|
|
77
111
|
|
|
78
112
|
def name
|
|
@@ -9,13 +9,27 @@ module AgentHarness
|
|
|
9
9
|
# Model name pattern for GitHub Copilot (uses OpenAI models)
|
|
10
10
|
MODEL_PATTERN = /^gpt-[\d.o-]+(?:-turbo)?(?:-mini)?$/i
|
|
11
11
|
|
|
12
|
+
# Copilot-specific smoke test contract. The `what-the-shell` subcommand
|
|
13
|
+
# translates natural language into shell commands, so the generic
|
|
14
|
+
# "Reply with exactly OK." prompt would produce something like
|
|
15
|
+
# `echo "OK"` rather than the literal text "OK". We use a prompt that
|
|
16
|
+
# is meaningful for the shell-translation path and only require
|
|
17
|
+
# non-empty output (no exact match).
|
|
18
|
+
SMOKE_TEST_CONTRACT = {
|
|
19
|
+
prompt: "list files in the current directory",
|
|
20
|
+
expected_output: nil,
|
|
21
|
+
timeout: 30,
|
|
22
|
+
require_output: true,
|
|
23
|
+
success_message: "Smoke test passed"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
12
26
|
class << self
|
|
13
27
|
def provider_name
|
|
14
28
|
:github_copilot
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
def binary_name
|
|
18
|
-
"copilot"
|
|
32
|
+
"github-copilot-cli"
|
|
19
33
|
end
|
|
20
34
|
|
|
21
35
|
def available?
|
|
@@ -56,6 +70,10 @@ module AgentHarness
|
|
|
56
70
|
]
|
|
57
71
|
end
|
|
58
72
|
|
|
73
|
+
def smoke_test_contract
|
|
74
|
+
SMOKE_TEST_CONTRACT
|
|
75
|
+
end
|
|
76
|
+
|
|
59
77
|
def model_family(provider_model_name)
|
|
60
78
|
provider_model_name
|
|
61
79
|
end
|
|
@@ -116,10 +134,10 @@ module AgentHarness
|
|
|
116
134
|
|
|
117
135
|
def execution_semantics
|
|
118
136
|
{
|
|
119
|
-
prompt_delivery: :
|
|
137
|
+
prompt_delivery: :arg,
|
|
120
138
|
output_format: :text,
|
|
121
139
|
sandbox_aware: false,
|
|
122
|
-
uses_subcommand:
|
|
140
|
+
uses_subcommand: true,
|
|
123
141
|
non_interactive_flag: nil,
|
|
124
142
|
legitimate_exit_codes: [0],
|
|
125
143
|
stderr_is_diagnostic: true,
|
|
@@ -155,10 +173,12 @@ module AgentHarness
|
|
|
155
173
|
protected
|
|
156
174
|
|
|
157
175
|
def build_command(prompt, options)
|
|
158
|
-
cmd = [self.class.binary_name, "-
|
|
176
|
+
cmd = [self.class.binary_name, "what-the-shell", prompt]
|
|
159
177
|
|
|
160
|
-
#
|
|
161
|
-
|
|
178
|
+
# Opt in to unrestricted tool access explicitly to preserve a safe default.
|
|
179
|
+
if supports_dangerous_mode? && options[:dangerous_mode]
|
|
180
|
+
cmd += dangerous_mode_flags
|
|
181
|
+
end
|
|
162
182
|
|
|
163
183
|
# Add session support if provided
|
|
164
184
|
if options[:session] && !options[:session].empty?
|
|
@@ -6,6 +6,10 @@ module AgentHarness
|
|
|
6
6
|
#
|
|
7
7
|
# Provides integration with the Kilocode CLI tool.
|
|
8
8
|
class Kilocode < Base
|
|
9
|
+
PACKAGE_NAME = "@kilocode/cli"
|
|
10
|
+
DEFAULT_VERSION = "7.1.3"
|
|
11
|
+
SUPPORTED_VERSION_REQUIREMENT = "= #{DEFAULT_VERSION}"
|
|
12
|
+
|
|
9
13
|
class << self
|
|
10
14
|
def provider_name
|
|
11
15
|
:kilocode
|
|
@@ -35,6 +39,41 @@ module AgentHarness
|
|
|
35
39
|
return [] unless available?
|
|
36
40
|
[]
|
|
37
41
|
end
|
|
42
|
+
|
|
43
|
+
def installation_contract(version: DEFAULT_VERSION)
|
|
44
|
+
validate_install_version!(version)
|
|
45
|
+
package_spec = "#{PACKAGE_NAME}@#{version}"
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
source: {
|
|
49
|
+
type: :npm,
|
|
50
|
+
package: PACKAGE_NAME
|
|
51
|
+
},
|
|
52
|
+
install_command: ["npm", "install", "-g", "--ignore-scripts", package_spec],
|
|
53
|
+
binary_name: binary_name,
|
|
54
|
+
default_version: DEFAULT_VERSION,
|
|
55
|
+
supported_version_requirement: SUPPORTED_VERSION_REQUIREMENT
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def install_command(version: DEFAULT_VERSION)
|
|
60
|
+
installation_contract(version: version)[:install_command]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def smoke_test_contract
|
|
64
|
+
Base::DEFAULT_SMOKE_TEST_CONTRACT
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def validate_install_version!(version)
|
|
70
|
+
requirement = Gem::Requirement.new(SUPPORTED_VERSION_REQUIREMENT)
|
|
71
|
+
return if requirement.satisfied_by?(Gem::Version.new(version))
|
|
72
|
+
|
|
73
|
+
raise ArgumentError,
|
|
74
|
+
"Unsupported Kilocode CLI version #{version.inspect}; " \
|
|
75
|
+
"supported versions must satisfy #{SUPPORTED_VERSION_REQUIREMENT}"
|
|
76
|
+
end
|
|
38
77
|
end
|
|
39
78
|
|
|
40
79
|
def name
|
|
@@ -6,6 +6,15 @@ module AgentHarness
|
|
|
6
6
|
#
|
|
7
7
|
# Provides integration with the OpenCode CLI tool.
|
|
8
8
|
class Opencode < Base
|
|
9
|
+
CLI_PACKAGE = "opencode-ai"
|
|
10
|
+
SUPPORTED_CLI_VERSION = "1.3.2"
|
|
11
|
+
SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 1.4.0").freeze
|
|
12
|
+
INSTALL_COMMAND_PREFIX = ["npm", "install", "-g", "--ignore-scripts"].freeze
|
|
13
|
+
SUPPORTED_CLI_VERSIONS = [SUPPORTED_CLI_VERSION].freeze
|
|
14
|
+
VERSION_REQUIREMENT_STRINGS = SUPPORTED_CLI_REQUIREMENT.requirements
|
|
15
|
+
.map { |op, ver| "#{op} #{ver}".freeze }
|
|
16
|
+
.freeze
|
|
17
|
+
|
|
9
18
|
class << self
|
|
10
19
|
def provider_name
|
|
11
20
|
:opencode
|
|
@@ -37,8 +46,66 @@ module AgentHarness
|
|
|
37
46
|
return [] unless available?
|
|
38
47
|
[]
|
|
39
48
|
end
|
|
49
|
+
|
|
50
|
+
def installation_contract(version: SUPPORTED_CLI_VERSION)
|
|
51
|
+
normalized_version = normalize_install_version(version)
|
|
52
|
+
return DEFAULT_INSTALLATION_CONTRACT if normalized_version == SUPPORTED_CLI_VERSION
|
|
53
|
+
|
|
54
|
+
build_installation_contract(normalized_version)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def install_command(version: SUPPORTED_CLI_VERSION)
|
|
58
|
+
installation_contract(version: version)[:install_command]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def smoke_test_contract
|
|
62
|
+
Base::DEFAULT_SMOKE_TEST_CONTRACT
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def build_installation_contract(version)
|
|
68
|
+
package = "#{CLI_PACKAGE}@#{version}".freeze
|
|
69
|
+
install_command = (INSTALL_COMMAND_PREFIX + [package]).freeze
|
|
70
|
+
|
|
71
|
+
contract = {
|
|
72
|
+
source: :npm,
|
|
73
|
+
package: package,
|
|
74
|
+
package_name: CLI_PACKAGE,
|
|
75
|
+
version: version,
|
|
76
|
+
version_requirement: VERSION_REQUIREMENT_STRINGS,
|
|
77
|
+
binary_name: binary_name,
|
|
78
|
+
install_command_prefix: INSTALL_COMMAND_PREFIX,
|
|
79
|
+
install_command: install_command,
|
|
80
|
+
supported_versions: SUPPORTED_CLI_VERSIONS
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
contract.each_value do |value|
|
|
84
|
+
value.freeze if value.is_a?(String)
|
|
85
|
+
end
|
|
86
|
+
contract.freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_install_version(version)
|
|
90
|
+
raise ArgumentError, unsupported_version_message(version) unless version.is_a?(String) && !version.strip.empty?
|
|
91
|
+
|
|
92
|
+
normalized_version = version.strip
|
|
93
|
+
parsed_version = Gem::Version.new(normalized_version)
|
|
94
|
+
return normalized_version if SUPPORTED_CLI_REQUIREMENT.satisfied_by?(parsed_version)
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, unsupported_version_message(version)
|
|
97
|
+
rescue ArgumentError
|
|
98
|
+
raise ArgumentError, unsupported_version_message(version)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def unsupported_version_message(version)
|
|
102
|
+
"Unsupported OpenCode CLI version #{version.inspect}; " \
|
|
103
|
+
"supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
|
|
104
|
+
end
|
|
40
105
|
end
|
|
41
106
|
|
|
107
|
+
DEFAULT_INSTALLATION_CONTRACT = build_installation_contract(SUPPORTED_CLI_VERSION)
|
|
108
|
+
|
|
42
109
|
def name
|
|
43
110
|
"opencode"
|
|
44
111
|
end
|
|
@@ -87,7 +154,7 @@ module AgentHarness
|
|
|
87
154
|
protected
|
|
88
155
|
|
|
89
156
|
def build_command(prompt, options)
|
|
90
|
-
cmd = [self.class.binary_name, "run"]
|
|
157
|
+
cmd = [self.class.installation_contract[:binary_name], "run"]
|
|
91
158
|
|
|
92
159
|
runtime = options[:provider_runtime]
|
|
93
160
|
if runtime
|
|
@@ -79,6 +79,60 @@ module AgentHarness
|
|
|
79
79
|
@providers.select { |_, klass| klass.available? }.keys
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# Fetch installation metadata for a provider.
|
|
83
|
+
#
|
|
84
|
+
# @param name [Symbol, String] the provider name
|
|
85
|
+
# @param options [Hash] optional target selection (for example, `version:`)
|
|
86
|
+
# @return [Hash, nil] provider installation contract, or nil when the
|
|
87
|
+
# registered provider class does not define `.installation_contract`
|
|
88
|
+
# @raise [ConfigurationError] if provider not found
|
|
89
|
+
def installation_contract(name, **options)
|
|
90
|
+
provider_class = get(name)
|
|
91
|
+
return nil unless provider_class.respond_to?(:installation_contract)
|
|
92
|
+
|
|
93
|
+
provider_class.installation_contract(**options)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get installation metadata for all providers that expose it.
|
|
97
|
+
#
|
|
98
|
+
# @return [Hash<Symbol, Hash>] installation contracts keyed by provider
|
|
99
|
+
def installation_contracts
|
|
100
|
+
ensure_builtin_providers_registered
|
|
101
|
+
|
|
102
|
+
@providers.each_with_object({}) do |(name, klass), contracts|
|
|
103
|
+
next unless klass.respond_to?(:installation_contract)
|
|
104
|
+
|
|
105
|
+
contract = klass.installation_contract
|
|
106
|
+
contracts[name] = contract if contract
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get smoke-test metadata for a provider.
|
|
111
|
+
#
|
|
112
|
+
# @param name [Symbol, String] the provider name
|
|
113
|
+
# @return [Hash, nil] smoke-test contract
|
|
114
|
+
# @raise [ConfigurationError] if the provider name is not registered
|
|
115
|
+
def smoke_test_contract(name)
|
|
116
|
+
klass = get(name)
|
|
117
|
+
return nil unless klass.respond_to?(:smoke_test_contract)
|
|
118
|
+
|
|
119
|
+
klass.smoke_test_contract
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get smoke-test metadata for all providers that expose it.
|
|
123
|
+
#
|
|
124
|
+
# @return [Hash<Symbol, Hash>] smoke-test contracts keyed by provider
|
|
125
|
+
def smoke_test_contracts
|
|
126
|
+
ensure_builtin_providers_registered
|
|
127
|
+
|
|
128
|
+
@providers.each_with_object({}) do |(name, klass), contracts|
|
|
129
|
+
next unless klass.respond_to?(:smoke_test_contract)
|
|
130
|
+
|
|
131
|
+
contract = klass.smoke_test_contract
|
|
132
|
+
contracts[name] = contract if contract
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
82
136
|
# Reset registry (useful for testing)
|
|
83
137
|
#
|
|
84
138
|
# @return [void]
|