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.
- checksums.yaml +7 -0
- data/.markdownlint.yml +6 -0
- data/.markdownlintignore +8 -0
- data/.release-please-manifest.json +3 -0
- data/.rspec +3 -0
- data/.simplecov +26 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +27 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +274 -0
- data/Rakefile +103 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/lib/agent_harness/command_executor.rb +146 -0
- data/lib/agent_harness/configuration.rb +299 -0
- data/lib/agent_harness/error_taxonomy.rb +128 -0
- data/lib/agent_harness/errors.rb +63 -0
- data/lib/agent_harness/orchestration/circuit_breaker.rb +169 -0
- data/lib/agent_harness/orchestration/conductor.rb +179 -0
- data/lib/agent_harness/orchestration/health_monitor.rb +170 -0
- data/lib/agent_harness/orchestration/metrics.rb +167 -0
- data/lib/agent_harness/orchestration/provider_manager.rb +240 -0
- data/lib/agent_harness/orchestration/rate_limiter.rb +113 -0
- data/lib/agent_harness/providers/adapter.rb +163 -0
- data/lib/agent_harness/providers/aider.rb +109 -0
- data/lib/agent_harness/providers/anthropic.rb +345 -0
- data/lib/agent_harness/providers/base.rb +198 -0
- data/lib/agent_harness/providers/codex.rb +100 -0
- data/lib/agent_harness/providers/cursor.rb +281 -0
- data/lib/agent_harness/providers/gemini.rb +136 -0
- data/lib/agent_harness/providers/github_copilot.rb +155 -0
- data/lib/agent_harness/providers/kilocode.rb +73 -0
- data/lib/agent_harness/providers/opencode.rb +75 -0
- data/lib/agent_harness/providers/registry.rb +137 -0
- data/lib/agent_harness/response.rb +100 -0
- data/lib/agent_harness/token_tracker.rb +170 -0
- data/lib/agent_harness/version.rb +5 -0
- data/lib/agent_harness.rb +115 -0
- data/release-please-config.json +63 -0
- 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
|