agent-harness 0.5.5 → 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.
@@ -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
@@ -83,6 +117,25 @@ module AgentHarness
83
117
  "Google Gemini"
84
118
  end
85
119
 
120
+ def configuration_schema
121
+ {
122
+ fields: [
123
+ {
124
+ name: :model,
125
+ type: :string,
126
+ label: "Model",
127
+ required: false,
128
+ hint: "Gemini model to use (e.g. gemini-2.5-pro, gemini-2.0-flash)",
129
+ # accepts_arbitrary is true because supports_model_family? accepts
130
+ # any string starting with "gemini-", not just discovered models.
131
+ accepts_arbitrary: true
132
+ }
133
+ ],
134
+ auth_modes: [:api_key, :oauth],
135
+ openai_compatible: false
136
+ }
137
+ end
138
+
86
139
  def capabilities
87
140
  {
88
141
  streaming: true,
@@ -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
@@ -77,6 +95,14 @@ module AgentHarness
77
95
  "GitHub Copilot CLI"
78
96
  end
79
97
 
98
+ def configuration_schema
99
+ {
100
+ fields: [],
101
+ auth_modes: [:oauth],
102
+ openai_compatible: false
103
+ }
104
+ end
105
+
80
106
  def capabilities
81
107
  {
82
108
  streaming: false,
@@ -108,10 +134,10 @@ module AgentHarness
108
134
 
109
135
  def execution_semantics
110
136
  {
111
- prompt_delivery: :flag,
137
+ prompt_delivery: :arg,
112
138
  output_format: :text,
113
139
  sandbox_aware: false,
114
- uses_subcommand: false,
140
+ uses_subcommand: true,
115
141
  non_interactive_flag: nil,
116
142
  legitimate_exit_codes: [0],
117
143
  stderr_is_diagnostic: true,
@@ -147,10 +173,12 @@ module AgentHarness
147
173
  protected
148
174
 
149
175
  def build_command(prompt, options)
150
- cmd = [self.class.binary_name, "-p", prompt]
176
+ cmd = [self.class.binary_name, "what-the-shell", prompt]
151
177
 
152
- # Add dangerous mode flags by default for automation
153
- 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
154
182
 
155
183
  # Add session support if provided
156
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
@@ -47,6 +114,14 @@ module AgentHarness
47
114
  "OpenCode CLI"
48
115
  end
49
116
 
117
+ def configuration_schema
118
+ {
119
+ fields: [],
120
+ auth_modes: [:api_key],
121
+ openai_compatible: true
122
+ }
123
+ end
124
+
50
125
  def capabilities
51
126
  {
52
127
  streaming: false,
@@ -79,11 +154,26 @@ module AgentHarness
79
154
  protected
80
155
 
81
156
  def build_command(prompt, options)
82
- cmd = [self.class.binary_name, "run"]
157
+ cmd = [self.class.installation_contract[:binary_name], "run"]
158
+
159
+ runtime = options[:provider_runtime]
160
+ if runtime
161
+ cmd += runtime.flags unless runtime.flags.empty?
162
+ end
163
+
83
164
  cmd << prompt
84
165
  cmd
85
166
  end
86
167
 
168
+ def build_env(options)
169
+ env = super
170
+ runtime = options[:provider_runtime]
171
+ return env unless runtime
172
+
173
+ env["OPENAI_BASE_URL"] = runtime.base_url if runtime.base_url
174
+ env
175
+ end
176
+
87
177
  def default_timeout
88
178
  300
89
179
  end
@@ -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.5"
4
+ VERSION = "0.5.7"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -70,10 +70,11 @@ module AgentHarness
70
70
  # Send a message using the orchestration layer
71
71
  # @param prompt [String] the prompt to send
72
72
  # @param provider [Symbol, nil] optional provider override
73
+ # @param executor [CommandExecutor, nil] per-request executor override
73
74
  # @param options [Hash] additional options
74
75
  # @return [Response] the response from the provider
75
- def send_message(prompt, provider: nil, **options)
76
- conductor.send_message(prompt, provider: provider, **options)
76
+ def send_message(prompt, provider: nil, executor: nil, **options)
77
+ conductor.send_message(prompt, provider: provider, executor: executor, **options)
77
78
  end
78
79
 
79
80
  # Get a provider instance
@@ -83,6 +84,64 @@ module AgentHarness
83
84
  conductor.provider_manager.get_provider(name)
84
85
  end
85
86
 
87
+ # Returns install metadata for a provider CLI when the provider exposes it.
88
+ #
89
+ # @param provider_name [Symbol, String] the provider name
90
+ # @param version [String, nil] optional explicit CLI version override
91
+ # @return [Hash, nil] installation metadata
92
+ def provider_install_contract(provider_name, version: nil)
93
+ provider_installation_contract(provider_name, **(version ? {version: version} : {}))
94
+ end
95
+
96
+ # Get the installation contract for a provider CLI.
97
+ #
98
+ # @param name [Symbol, String] the provider name
99
+ # @param options [Hash] optional target selection (for example, `version:`)
100
+ # @return [Hash, nil] provider installation contract for the requested target
101
+ # @raise [ConfigurationError] if provider not found
102
+ def provider_installation_contract(name, **options)
103
+ Providers::Registry.instance.installation_contract(name, **options)
104
+ end
105
+
106
+ # Get installation metadata for a provider CLI.
107
+ # @param provider_name [Symbol, String] the provider name
108
+ # @param options [Hash] optional target selection (for example, `version:`)
109
+ # @return [Hash, nil] installation contract
110
+ # @raise [ConfigurationError] if the provider name is not registered
111
+ def installation_contract(provider_name, **options)
112
+ Providers::Registry.instance.installation_contract(provider_name, **options)
113
+ end
114
+
115
+ # Get all provider installation contracts exposed by agent-harness.
116
+ # @return [Hash<Symbol, Hash>] installation contracts keyed by provider
117
+ def installation_contracts
118
+ Providers::Registry.instance.installation_contracts
119
+ end
120
+
121
+ # Get smoke-test metadata for a provider CLI when the provider exposes it.
122
+ #
123
+ # @param provider_name [Symbol, String] the provider name
124
+ # @return [Hash, nil] smoke-test contract
125
+ def provider_smoke_test_contract(provider_name)
126
+ smoke_test_contract(provider_name)
127
+ end
128
+
129
+ # Get smoke-test metadata for a provider CLI.
130
+ # @param provider_name [Symbol, String] the provider name
131
+ # @return [Hash, nil] smoke-test contract
132
+ # @raise [ConfigurationError] if the provider name is not registered
133
+ def smoke_test_contract(provider_name)
134
+ # Explicitly raise if provider is not registered to match documentation
135
+ raise ConfigurationError, "Unknown provider: #{provider_name}" unless Providers::Registry.instance.registered?(provider_name)
136
+ Providers::Registry.instance.smoke_test_contract(provider_name)
137
+ end
138
+
139
+ # Get all provider smoke-test contracts exposed by agent-harness.
140
+ # @return [Hash<Symbol, Hash>] smoke-test contracts keyed by provider
141
+ def smoke_test_contracts
142
+ Providers::Registry.instance.smoke_test_contracts
143
+ end
144
+
86
145
  # Check if authentication is valid for a provider
87
146
  # @param provider_name [Symbol] the provider name
88
147
  # @return [Boolean] true if auth is valid
@@ -120,17 +179,29 @@ module AgentHarness
120
179
  # authentication, provider health status, and config validation checks.
121
180
  #
122
181
  # @param timeout [Integer] timeout in seconds for each check (defaults to configured value)
182
+ # @raise [ArgumentError] if provider_runtime is supplied; runtime overrides are
183
+ # only supported by `check_provider` to avoid leaking one provider's execution
184
+ # context into every other health check
123
185
  # @return [Array<Hash>] health status for each provider
124
- def check_providers(timeout: nil)
125
- timeout ? ProviderHealthCheck.check_all(timeout: timeout) : ProviderHealthCheck.check_all
186
+ def check_providers(timeout: nil, executor: nil, provider_runtime: nil)
187
+ raise ArgumentError, "provider_runtime is only supported for single-provider health checks" unless provider_runtime.nil?
188
+
189
+ options = {}
190
+ options[:timeout] = timeout unless timeout.nil?
191
+ options[:executor] = executor unless executor.nil?
192
+ ProviderHealthCheck.check_all(**options)
126
193
  end
127
194
 
128
195
  # Check health of a single provider
129
196
  # @param provider_name [Symbol] the provider name
130
197
  # @param timeout [Integer, nil] timeout in seconds (nil lets ProviderHealthCheck apply its validated default)
131
198
  # @return [Hash] health status with :name, :status, :message, :latency_ms
132
- def check_provider(provider_name, timeout: nil)
133
- timeout ? ProviderHealthCheck.check(provider_name, timeout: timeout) : ProviderHealthCheck.check(provider_name)
199
+ def check_provider(provider_name, timeout: nil, executor: nil, provider_runtime: nil)
200
+ options = {}
201
+ options[:timeout] = timeout unless timeout.nil?
202
+ options[:executor] = executor unless executor.nil?
203
+ options[:provider_runtime] = provider_runtime unless provider_runtime.nil?
204
+ ProviderHealthCheck.check(provider_name, **options)
134
205
  end
135
206
  end
136
207
  end
@@ -138,6 +209,7 @@ end
138
209
  # Core components
139
210
  require_relative "agent_harness/errors"
140
211
  require_relative "agent_harness/mcp_server"
212
+ require_relative "agent_harness/provider_runtime"
141
213
  require_relative "agent_harness/configuration"
142
214
  require_relative "agent_harness/command_executor"
143
215
  require_relative "agent_harness/docker_command_executor"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.5
4
+ version: 0.5.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -9,6 +9,26 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.6'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '2.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '1.6'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '2.0'
12
32
  - !ruby/object:Gem::Dependency
13
33
  name: rake
14
34
  requirement: !ruby/object:Gem::Requirement
@@ -92,6 +112,7 @@ files:
92
112
  - lib/agent_harness/orchestration/provider_manager.rb
93
113
  - lib/agent_harness/orchestration/rate_limiter.rb
94
114
  - lib/agent_harness/provider_health_check.rb
115
+ - lib/agent_harness/provider_runtime.rb
95
116
  - lib/agent_harness/providers/adapter.rb
96
117
  - lib/agent_harness/providers/aider.rb
97
118
  - lib/agent_harness/providers/anthropic.rb