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.
@@ -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(command, timeout: timeout, env: build_env(options))
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
- runtime.env.dup
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 execute_with_timeout(command, timeout:, env:)
283
- @executor.execute(command, timeout: timeout, env: env)
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
- # When running inside an already-sandboxed Docker container, Codex's
199
- # own sandboxing conflicts with the outer sandbox. Use --full-auto to
200
- # skip nested sandboxing while keeping full tool access.
201
- # Also applies when dangerous_mode is explicitly requested.
202
- if sandboxed_environment? || options[:dangerous_mode]
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?(options)
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
- cmd += runtime.flags unless runtime.flags.empty?
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", "none"]
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 = @executor.execute(command, timeout: timeout, stdin_data: prompt, env: env)
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: :flag,
137
+ prompt_delivery: :arg,
120
138
  output_format: :text,
121
139
  sandbox_aware: false,
122
- uses_subcommand: false,
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, "-p", prompt]
176
+ cmd = [self.class.binary_name, "what-the-shell", prompt]
159
177
 
160
- # Add dangerous mode flags by default for automation
161
- cmd += dangerous_mode_flags if supports_dangerous_mode?
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
@@ -37,6 +37,10 @@ module AgentHarness
37
37
  return [] unless available?
38
38
  []
39
39
  end
40
+
41
+ def smoke_test_contract
42
+ Base::DEFAULT_SMOKE_TEST_CONTRACT
43
+ end
40
44
  end
41
45
 
42
46
  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]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.5.6"
4
+ VERSION = "0.5.7"
5
5
  end