agent-harness 0.2.1

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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.markdownlint.yml +6 -0
  3. data/.markdownlintignore +8 -0
  4. data/.release-please-manifest.json +3 -0
  5. data/.rspec +3 -0
  6. data/.simplecov +26 -0
  7. data/.tool-versions +1 -0
  8. data/CHANGELOG.md +27 -0
  9. data/CODE_OF_CONDUCT.md +10 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +274 -0
  12. data/Rakefile +103 -0
  13. data/bin/console +11 -0
  14. data/bin/setup +8 -0
  15. data/lib/agent_harness/command_executor.rb +146 -0
  16. data/lib/agent_harness/configuration.rb +299 -0
  17. data/lib/agent_harness/error_taxonomy.rb +128 -0
  18. data/lib/agent_harness/errors.rb +63 -0
  19. data/lib/agent_harness/orchestration/circuit_breaker.rb +169 -0
  20. data/lib/agent_harness/orchestration/conductor.rb +179 -0
  21. data/lib/agent_harness/orchestration/health_monitor.rb +170 -0
  22. data/lib/agent_harness/orchestration/metrics.rb +167 -0
  23. data/lib/agent_harness/orchestration/provider_manager.rb +240 -0
  24. data/lib/agent_harness/orchestration/rate_limiter.rb +113 -0
  25. data/lib/agent_harness/providers/adapter.rb +163 -0
  26. data/lib/agent_harness/providers/aider.rb +109 -0
  27. data/lib/agent_harness/providers/anthropic.rb +345 -0
  28. data/lib/agent_harness/providers/base.rb +198 -0
  29. data/lib/agent_harness/providers/codex.rb +100 -0
  30. data/lib/agent_harness/providers/cursor.rb +281 -0
  31. data/lib/agent_harness/providers/gemini.rb +136 -0
  32. data/lib/agent_harness/providers/github_copilot.rb +155 -0
  33. data/lib/agent_harness/providers/kilocode.rb +73 -0
  34. data/lib/agent_harness/providers/opencode.rb +75 -0
  35. data/lib/agent_harness/providers/registry.rb +137 -0
  36. data/lib/agent_harness/response.rb +100 -0
  37. data/lib/agent_harness/token_tracker.rb +170 -0
  38. data/lib/agent_harness/version.rb +5 -0
  39. data/lib/agent_harness.rb +115 -0
  40. data/release-please-config.json +63 -0
  41. metadata +129 -0
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Orchestration
5
+ # Manages provider instances and selection
6
+ #
7
+ # Handles provider lifecycle, health tracking, circuit breakers,
8
+ # and rate limiters. Provides intelligent provider selection based
9
+ # on availability and health.
10
+ class ProviderManager
11
+ attr_reader :current_provider, :provider_instances
12
+
13
+ # Create a new provider manager
14
+ #
15
+ # @param config [Configuration] the configuration
16
+ def initialize(config)
17
+ @config = config
18
+ @registry = Providers::Registry.instance
19
+ @provider_instances = {}
20
+ @current_provider = config.default_provider
21
+
22
+ @circuit_breakers = {}
23
+ @rate_limiters = {}
24
+ @health_monitor = HealthMonitor.new(config.orchestration_config.health_check_config)
25
+ @fallback_chains = {}
26
+
27
+ initialize_providers
28
+ end
29
+
30
+ # Select best available provider
31
+ #
32
+ # @param preferred [Symbol, nil] preferred provider name
33
+ # @return [Providers::Base] selected provider instance
34
+ # @raise [NoProvidersAvailableError] if no providers available
35
+ def select_provider(preferred = nil)
36
+ preferred ||= @current_provider
37
+
38
+ # Check circuit breaker
39
+ if circuit_open?(preferred)
40
+ return select_fallback(preferred, reason: :circuit_open)
41
+ end
42
+
43
+ # Check rate limit
44
+ if rate_limited?(preferred)
45
+ return select_fallback(preferred, reason: :rate_limited)
46
+ end
47
+
48
+ # Check health
49
+ unless healthy?(preferred)
50
+ return select_fallback(preferred, reason: :unhealthy)
51
+ end
52
+
53
+ get_provider(preferred)
54
+ end
55
+
56
+ # Get or create provider instance
57
+ #
58
+ # @param name [Symbol, String] the provider name
59
+ # @return [Providers::Base] the provider instance
60
+ def get_provider(name)
61
+ name = name.to_sym
62
+ @provider_instances[name] ||= create_provider(name)
63
+ end
64
+
65
+ # Switch to next available provider
66
+ #
67
+ # @param reason [Symbol, String] reason for switch
68
+ # @param context [Hash] additional context
69
+ # @return [Providers::Base, nil] new provider or nil if none available
70
+ def switch_provider(reason:, context: {})
71
+ old_provider = @current_provider
72
+
73
+ fallback = select_fallback(@current_provider, reason: reason)
74
+ return nil unless fallback
75
+
76
+ @current_provider = fallback.class.provider_name
77
+
78
+ AgentHarness.logger&.info(
79
+ "[AgentHarness] Provider switch: #{old_provider} -> #{@current_provider} (#{reason})"
80
+ )
81
+
82
+ @config.callbacks.emit(:provider_switch, {
83
+ from: old_provider,
84
+ to: @current_provider,
85
+ reason: reason,
86
+ context: context
87
+ })
88
+
89
+ fallback
90
+ end
91
+
92
+ # Record success for provider
93
+ #
94
+ # @param provider_name [Symbol, String] the provider name
95
+ # @return [void]
96
+ def record_success(provider_name)
97
+ provider_name = provider_name.to_sym
98
+ @health_monitor.record_success(provider_name)
99
+ @circuit_breakers[provider_name]&.record_success
100
+ end
101
+
102
+ # Record failure for provider
103
+ #
104
+ # @param provider_name [Symbol, String] the provider name
105
+ # @return [void]
106
+ def record_failure(provider_name)
107
+ provider_name = provider_name.to_sym
108
+ @health_monitor.record_failure(provider_name)
109
+ @circuit_breakers[provider_name]&.record_failure
110
+ end
111
+
112
+ # Mark provider as rate limited
113
+ #
114
+ # @param provider_name [Symbol, String] the provider name
115
+ # @param reset_at [Time, nil] when the limit resets
116
+ # @return [void]
117
+ def mark_rate_limited(provider_name, reset_at: nil)
118
+ provider_name = provider_name.to_sym
119
+ @rate_limiters[provider_name]&.mark_limited(reset_at: reset_at)
120
+ end
121
+
122
+ # Get available providers
123
+ #
124
+ # @return [Array<Symbol>] available provider names
125
+ def available_providers
126
+ @provider_instances.keys.select do |name|
127
+ !circuit_open?(name) && !rate_limited?(name) && healthy?(name)
128
+ end
129
+ end
130
+
131
+ # Get health status for all providers
132
+ #
133
+ # @return [Array<Hash>] health status for each provider
134
+ def health_status
135
+ @provider_instances.keys.map do |name|
136
+ {
137
+ provider: name,
138
+ healthy: healthy?(name),
139
+ circuit_open: circuit_open?(name),
140
+ rate_limited: rate_limited?(name),
141
+ metrics: @health_monitor.metrics_for(name)
142
+ }
143
+ end
144
+ end
145
+
146
+ # Reset all state
147
+ #
148
+ # @return [void]
149
+ def reset!
150
+ @circuit_breakers.each_value(&:reset!)
151
+ @rate_limiters.each_value(&:reset!)
152
+ @health_monitor.reset!
153
+ @current_provider = @config.default_provider
154
+ end
155
+
156
+ # Check if circuit is open for provider
157
+ #
158
+ # @param provider_name [Symbol, String] the provider name
159
+ # @return [Boolean] true if open
160
+ def circuit_open?(provider_name)
161
+ @circuit_breakers[provider_name.to_sym]&.open? || false
162
+ end
163
+
164
+ # Check if provider is rate limited
165
+ #
166
+ # @param provider_name [Symbol, String] the provider name
167
+ # @return [Boolean] true if limited
168
+ def rate_limited?(provider_name)
169
+ @rate_limiters[provider_name.to_sym]&.limited? || false
170
+ end
171
+
172
+ # Check if provider is healthy
173
+ #
174
+ # @param provider_name [Symbol, String] the provider name
175
+ # @return [Boolean] true if healthy
176
+ def healthy?(provider_name)
177
+ @health_monitor.healthy?(provider_name.to_sym)
178
+ end
179
+
180
+ private
181
+
182
+ def initialize_providers
183
+ @config.providers.each do |name, provider_config|
184
+ next unless provider_config.enabled
185
+
186
+ @circuit_breakers[name] = CircuitBreaker.new(
187
+ @config.orchestration_config.circuit_breaker_config
188
+ )
189
+
190
+ @rate_limiters[name] = RateLimiter.new(
191
+ @config.orchestration_config.rate_limit_config
192
+ )
193
+
194
+ @fallback_chains[name] = build_fallback_chain(name)
195
+ end
196
+ end
197
+
198
+ def create_provider(name)
199
+ klass = @registry.get(name)
200
+ config = @config.providers[name]
201
+
202
+ klass.new(
203
+ config: config,
204
+ executor: @config.command_executor,
205
+ logger: AgentHarness.logger
206
+ )
207
+ end
208
+
209
+ def select_fallback(provider_name, reason:)
210
+ chain = @fallback_chains[provider_name] || build_fallback_chain(provider_name)
211
+
212
+ chain.each do |fallback_name|
213
+ next if fallback_name == provider_name
214
+ next if circuit_open?(fallback_name)
215
+ next if rate_limited?(fallback_name)
216
+ next unless healthy?(fallback_name)
217
+
218
+ AgentHarness.logger&.debug(
219
+ "[AgentHarness::ProviderManager] Falling back from #{provider_name} to #{fallback_name} (#{reason})"
220
+ )
221
+
222
+ return get_provider(fallback_name)
223
+ end
224
+
225
+ # No fallback available
226
+ raise NoProvidersAvailableError.new(
227
+ "No providers available after #{provider_name} (#{reason})",
228
+ attempted_providers: chain,
229
+ errors: {provider_name => reason.to_s}
230
+ )
231
+ end
232
+
233
+ def build_fallback_chain(provider_name)
234
+ chain = [provider_name] + @config.fallback_providers
235
+ chain += @config.providers.keys
236
+ chain.uniq
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Orchestration
5
+ # Rate limiter for tracking and managing provider rate limits
6
+ #
7
+ # Tracks rate limit events and provides information about when
8
+ # providers are expected to be available again.
9
+ #
10
+ # @example
11
+ # limiter = RateLimiter.new
12
+ # limiter.mark_limited(reset_at: Time.now + 3600)
13
+ # limiter.limited? # => true
14
+ class RateLimiter
15
+ attr_reader :limited_until, :limit_count
16
+
17
+ # Create a new rate limiter
18
+ #
19
+ # @param config [RateLimitConfig, nil] configuration object
20
+ # @param default_reset_time [Integer] default seconds until reset
21
+ def initialize(config = nil, default_reset_time: nil)
22
+ if config
23
+ @enabled = config.enabled
24
+ @default_reset_time = config.default_reset_time
25
+ else
26
+ @enabled = true
27
+ @default_reset_time = default_reset_time || 3600
28
+ end
29
+
30
+ reset!
31
+ end
32
+
33
+ # Check if currently rate limited
34
+ #
35
+ # @return [Boolean] true if rate limited
36
+ def limited?
37
+ return false unless @enabled
38
+ return false unless @limited_until
39
+
40
+ if Time.now >= @limited_until
41
+ clear_limit
42
+ false
43
+ else
44
+ true
45
+ end
46
+ end
47
+
48
+ # Mark as rate limited
49
+ #
50
+ # @param reset_at [Time, nil] when the limit resets
51
+ # @param reset_in [Integer, nil] seconds until reset
52
+ # @return [void]
53
+ def mark_limited(reset_at: nil, reset_in: nil)
54
+ @mutex.synchronize do
55
+ @limit_count += 1
56
+
57
+ @limited_until = if reset_at
58
+ reset_at
59
+ elsif reset_in
60
+ Time.now + reset_in
61
+ else
62
+ Time.now + @default_reset_time
63
+ end
64
+
65
+ AgentHarness.logger&.warn(
66
+ "[AgentHarness::RateLimiter] Rate limited until #{@limited_until}"
67
+ )
68
+ end
69
+ end
70
+
71
+ # Clear rate limit status
72
+ #
73
+ # @return [void]
74
+ def clear_limit
75
+ @mutex.synchronize do
76
+ @limited_until = nil
77
+ end
78
+ end
79
+
80
+ # Get time until limit resets
81
+ #
82
+ # @return [Integer, nil] seconds until reset, or nil if not limited
83
+ def time_until_reset
84
+ return nil unless @limited_until
85
+
86
+ remaining = @limited_until - Time.now
87
+ remaining.positive? ? remaining.to_i : 0
88
+ end
89
+
90
+ # Reset the rate limiter
91
+ #
92
+ # @return [void]
93
+ def reset!
94
+ @mutex = Mutex.new
95
+ @limited_until = nil
96
+ @limit_count = 0
97
+ end
98
+
99
+ # Get rate limiter status
100
+ #
101
+ # @return [Hash] status information
102
+ def status
103
+ {
104
+ limited: limited?,
105
+ limited_until: @limited_until,
106
+ time_until_reset: time_until_reset,
107
+ limit_count: @limit_count,
108
+ enabled: @enabled
109
+ }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Providers
5
+ # Interface that all providers must implement
6
+ #
7
+ # This module defines the contract that provider implementations must follow.
8
+ # Include this module in provider classes to ensure they implement the required interface.
9
+ #
10
+ # @example Implementing a provider
11
+ # class MyProvider < AgentHarness::Providers::Base
12
+ # include AgentHarness::Providers::Adapter
13
+ #
14
+ # def self.provider_name
15
+ # :my_provider
16
+ # end
17
+ # end
18
+ module Adapter
19
+ def self.included(base)
20
+ base.extend(ClassMethods)
21
+ end
22
+
23
+ # Class methods that all providers must implement
24
+ module ClassMethods
25
+ # Human-readable provider name
26
+ #
27
+ # @return [Symbol] unique identifier for this provider
28
+ def provider_name
29
+ raise NotImplementedError, "#{self} must implement .provider_name"
30
+ end
31
+
32
+ # Check if provider CLI is available on the system
33
+ #
34
+ # @return [Boolean] true if the CLI is installed and accessible
35
+ def available?
36
+ raise NotImplementedError, "#{self} must implement .available?"
37
+ end
38
+
39
+ # CLI binary name
40
+ #
41
+ # @return [String] the name of the CLI binary
42
+ def binary_name
43
+ raise NotImplementedError, "#{self} must implement .binary_name"
44
+ end
45
+
46
+ # Required domains for firewall configuration
47
+ #
48
+ # @return [Hash] with :domains and :ip_ranges arrays
49
+ def firewall_requirements
50
+ {domains: [], ip_ranges: []}
51
+ end
52
+
53
+ # Paths to instruction files (e.g., CLAUDE.md, .cursorrules)
54
+ #
55
+ # @return [Array<Hash>] instruction file configurations
56
+ def instruction_file_paths
57
+ []
58
+ end
59
+
60
+ # Discover available models
61
+ #
62
+ # @return [Array<Hash>] list of available models
63
+ def discover_models
64
+ []
65
+ end
66
+ end
67
+
68
+ # Instance methods
69
+
70
+ # Send a message/prompt to the provider
71
+ #
72
+ # @param prompt [String] the prompt to send
73
+ # @param options [Hash] provider-specific options
74
+ # @option options [String] :model model to use
75
+ # @option options [Integer] :timeout timeout in seconds
76
+ # @option options [String] :session session identifier
77
+ # @option options [Boolean] :dangerous_mode skip permission checks
78
+ # @return [Response] response object with output and metadata
79
+ def send_message(prompt:, **options)
80
+ raise NotImplementedError, "#{self.class} must implement #send_message"
81
+ end
82
+
83
+ # Provider capabilities
84
+ #
85
+ # @return [Hash] capability flags
86
+ def capabilities
87
+ {
88
+ streaming: false,
89
+ file_upload: false,
90
+ vision: false,
91
+ tool_use: false,
92
+ json_mode: false,
93
+ mcp: false,
94
+ dangerous_mode: false
95
+ }
96
+ end
97
+
98
+ # Error patterns for classification
99
+ #
100
+ # @return [Hash<Symbol, Array<Regexp>>] error patterns by category
101
+ def error_patterns
102
+ {}
103
+ end
104
+
105
+ # Check if provider supports MCP
106
+ #
107
+ # @return [Boolean] true if MCP is supported
108
+ def supports_mcp?
109
+ capabilities[:mcp]
110
+ end
111
+
112
+ # Fetch configured MCP servers
113
+ #
114
+ # @return [Array<Hash>] MCP server configurations
115
+ def fetch_mcp_servers
116
+ []
117
+ end
118
+
119
+ # Check if provider supports dangerous mode
120
+ #
121
+ # @return [Boolean] true if dangerous mode is supported
122
+ def supports_dangerous_mode?
123
+ capabilities[:dangerous_mode]
124
+ end
125
+
126
+ # Get dangerous mode flags
127
+ #
128
+ # @return [Array<String>] CLI flags for dangerous mode
129
+ def dangerous_mode_flags
130
+ []
131
+ end
132
+
133
+ # Check if provider supports session continuation
134
+ #
135
+ # @return [Boolean] true if sessions are supported
136
+ def supports_sessions?
137
+ false
138
+ end
139
+
140
+ # Get session flags for continuation
141
+ #
142
+ # @param session_id [String] the session ID
143
+ # @return [Array<String>] CLI flags for session continuation
144
+ def session_flags(session_id)
145
+ []
146
+ end
147
+
148
+ # Validate provider configuration
149
+ #
150
+ # @return [Hash] with :valid, :errors keys
151
+ def validate_config
152
+ {valid: true, errors: []}
153
+ end
154
+
155
+ # Health check
156
+ #
157
+ # @return [Hash] with :healthy, :message keys
158
+ def health_status
159
+ {healthy: true, message: "OK"}
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Providers
5
+ # Aider AI coding assistant provider
6
+ #
7
+ # Provides integration with the Aider CLI tool.
8
+ class Aider < Base
9
+ class << self
10
+ def provider_name
11
+ :aider
12
+ end
13
+
14
+ def binary_name
15
+ "aider"
16
+ end
17
+
18
+ def available?
19
+ executor = AgentHarness.configuration.command_executor
20
+ !!executor.which(binary_name)
21
+ end
22
+
23
+ def firewall_requirements
24
+ {
25
+ domains: [
26
+ "api.openai.com",
27
+ "api.anthropic.com"
28
+ ],
29
+ ip_ranges: []
30
+ }
31
+ end
32
+
33
+ def instruction_file_paths
34
+ [
35
+ {
36
+ path: ".aider.conf.yml",
37
+ description: "Aider configuration file",
38
+ symlink: false
39
+ }
40
+ ]
41
+ end
42
+
43
+ def discover_models
44
+ return [] unless available?
45
+
46
+ # Aider supports multiple model providers
47
+ [
48
+ {name: "gpt-4o", family: "gpt-4o", tier: "standard", provider: "aider"},
49
+ {name: "claude-3-5-sonnet", family: "claude-3-5-sonnet", tier: "standard", provider: "aider"}
50
+ ]
51
+ end
52
+ end
53
+
54
+ def name
55
+ "aider"
56
+ end
57
+
58
+ def display_name
59
+ "Aider"
60
+ end
61
+
62
+ def capabilities
63
+ {
64
+ streaming: true,
65
+ file_upload: true,
66
+ vision: false,
67
+ tool_use: true,
68
+ json_mode: false,
69
+ mcp: false,
70
+ dangerous_mode: false
71
+ }
72
+ end
73
+
74
+ def supports_sessions?
75
+ true
76
+ end
77
+
78
+ def session_flags(session_id)
79
+ return [] unless session_id && !session_id.empty?
80
+ ["--restore-chat-history", session_id]
81
+ end
82
+
83
+ protected
84
+
85
+ def build_command(prompt, options)
86
+ cmd = [self.class.binary_name]
87
+
88
+ # Run in non-interactive mode
89
+ cmd << "--yes"
90
+
91
+ if @config.model && !@config.model.empty?
92
+ cmd += ["--model", @config.model]
93
+ end
94
+
95
+ if options[:session]
96
+ cmd += session_flags(options[:session])
97
+ end
98
+
99
+ cmd += ["--message", prompt]
100
+
101
+ cmd
102
+ end
103
+
104
+ def default_timeout
105
+ 600 # Aider can take longer
106
+ end
107
+ end
108
+ end
109
+ end