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,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AgentHarness
6
+ module Providers
7
+ # Cursor AI CLI provider
8
+ #
9
+ # Provides integration with the Cursor AI coding assistant via its CLI tool.
10
+ #
11
+ # @example Basic usage
12
+ # provider = AgentHarness::Providers::Cursor.new
13
+ # response = provider.send_message(prompt: "Hello!")
14
+ class Cursor < Base
15
+ class << self
16
+ def provider_name
17
+ :cursor
18
+ end
19
+
20
+ def binary_name
21
+ "cursor-agent"
22
+ end
23
+
24
+ def available?
25
+ executor = AgentHarness.configuration.command_executor
26
+ !!executor.which(binary_name)
27
+ end
28
+
29
+ def firewall_requirements
30
+ {
31
+ domains: [
32
+ "cursor.com",
33
+ "www.cursor.com",
34
+ "downloads.cursor.com",
35
+ "api.cursor.sh",
36
+ "cursor.sh",
37
+ "app.cursor.sh",
38
+ "www.cursor.sh",
39
+ "auth.cursor.sh",
40
+ "auth0.com",
41
+ "*.auth0.com"
42
+ ],
43
+ ip_ranges: []
44
+ }
45
+ end
46
+
47
+ def instruction_file_paths
48
+ [
49
+ {
50
+ path: ".cursorrules",
51
+ description: "Cursor AI agent instructions",
52
+ symlink: true
53
+ }
54
+ ]
55
+ end
56
+
57
+ def discover_models
58
+ return [] unless available?
59
+
60
+ # Cursor doesn't have a public model listing API
61
+ # Return common model families it supports
62
+ [
63
+ {name: "claude-3.5-sonnet", family: "claude-3-5-sonnet", tier: "standard", provider: "cursor"},
64
+ {name: "claude-3.5-haiku", family: "claude-3-5-haiku", tier: "mini", provider: "cursor"},
65
+ {name: "gpt-4o", family: "gpt-4o", tier: "standard", provider: "cursor"},
66
+ {name: "cursor-small", family: "cursor-small", tier: "mini", provider: "cursor"}
67
+ ]
68
+ end
69
+
70
+ # Normalize Cursor's model name to family name
71
+ def model_family(provider_model_name)
72
+ # Normalize cursor naming: "claude-3.5-sonnet" -> "claude-3-5-sonnet"
73
+ provider_model_name.gsub(/(\d)\.(\d)/, '\1-\2')
74
+ end
75
+
76
+ # Convert family name to Cursor's naming convention
77
+ def provider_model_name(family_name)
78
+ # Cursor uses dots: "claude-3-5-sonnet" -> "claude-3.5-sonnet"
79
+ family_name.gsub(/(\d)-(\d)/, '\1.\2')
80
+ end
81
+
82
+ # Check if this provider supports a given model family
83
+ def supports_model_family?(family_name)
84
+ family_name.match?(/^(claude|gpt|cursor)-/)
85
+ end
86
+ end
87
+
88
+ def name
89
+ "cursor"
90
+ end
91
+
92
+ def display_name
93
+ "Cursor AI"
94
+ end
95
+
96
+ def capabilities
97
+ {
98
+ streaming: false,
99
+ file_upload: true,
100
+ vision: false,
101
+ tool_use: true,
102
+ json_mode: false,
103
+ mcp: true,
104
+ dangerous_mode: false
105
+ }
106
+ end
107
+
108
+ def supports_mcp?
109
+ true
110
+ end
111
+
112
+ def fetch_mcp_servers
113
+ # Try CLI first, then config file
114
+ fetch_mcp_servers_cli || fetch_mcp_servers_config
115
+ end
116
+
117
+ def error_patterns
118
+ {
119
+ rate_limited: [
120
+ /rate.?limit/i,
121
+ /too.?many.?requests/i,
122
+ /429/
123
+ ],
124
+ auth_expired: [
125
+ /authentication.*error/i,
126
+ /invalid.*credentials/i,
127
+ /unauthorized/i
128
+ ],
129
+ transient: [
130
+ /timeout/i,
131
+ /connection.*error/i,
132
+ /temporary/i
133
+ ]
134
+ }
135
+ end
136
+
137
+ # Override send_message to send prompt via stdin
138
+ def send_message(prompt:, **options)
139
+ log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
140
+
141
+ # Build command (without prompt in args - we send via stdin)
142
+ command = [self.class.binary_name, "-p"]
143
+
144
+ # Calculate timeout
145
+ timeout = options[:timeout] || @config.timeout || default_timeout
146
+
147
+ # Execute command with prompt on stdin
148
+ start_time = Time.now
149
+ result = @executor.execute(command, timeout: timeout, stdin_data: prompt)
150
+ duration = Time.now - start_time
151
+
152
+ # Parse response
153
+ response = parse_response(result, duration: duration)
154
+
155
+ # Track tokens
156
+ track_tokens(response) if response.tokens
157
+
158
+ log_debug("send_message_complete", duration: duration)
159
+
160
+ response
161
+ rescue => e
162
+ handle_error(e, prompt: prompt, options: options)
163
+ end
164
+
165
+ protected
166
+
167
+ def build_command(prompt, options)
168
+ # Use -p mode (designed for non-interactive/script use)
169
+ [self.class.binary_name, "-p"]
170
+ end
171
+
172
+ def build_env(options)
173
+ {}
174
+ end
175
+
176
+ def default_timeout
177
+ 300
178
+ end
179
+
180
+ private
181
+
182
+ def fetch_mcp_servers_cli
183
+ return nil unless self.class.available?
184
+
185
+ begin
186
+ result = @executor.execute(["cursor-agent", "mcp", "list"], timeout: 5)
187
+ return nil unless result.success?
188
+
189
+ parse_mcp_servers_output(result.stdout)
190
+ rescue
191
+ nil
192
+ end
193
+ end
194
+
195
+ def fetch_mcp_servers_config
196
+ cursor_config_path = File.expand_path("~/.cursor/mcp.json")
197
+ return [] unless File.exist?(cursor_config_path)
198
+
199
+ begin
200
+ config = JSON.parse(File.read(cursor_config_path))
201
+ servers = []
202
+ mcp_servers = config["mcpServers"] || {}
203
+
204
+ mcp_servers.each do |name, server_config|
205
+ command_parts = [server_config["command"]]
206
+ command_parts.concat(server_config["args"]) if server_config["args"]
207
+ command_description = command_parts.join(" ")
208
+
209
+ servers << {
210
+ name: name,
211
+ status: "configured",
212
+ description: command_description,
213
+ enabled: true,
214
+ source: "cursor_config"
215
+ }
216
+ end
217
+
218
+ servers
219
+ rescue
220
+ []
221
+ end
222
+ end
223
+
224
+ def parse_mcp_servers_output(output)
225
+ servers = []
226
+ return servers unless output
227
+
228
+ output.lines.each do |line|
229
+ line = line.strip
230
+ next if line.empty?
231
+
232
+ if line =~ /^([^:]+):\s*(.+)$/
233
+ name = Regexp.last_match(1).strip
234
+ status = Regexp.last_match(2).strip
235
+
236
+ servers << {
237
+ name: name,
238
+ status: status,
239
+ enabled: status == "ready" || status == "connected",
240
+ source: "cursor_cli"
241
+ }
242
+ end
243
+ end
244
+
245
+ servers
246
+ end
247
+
248
+ def log_debug(action, **context)
249
+ @logger&.debug("[AgentHarness::Cursor] #{action}: #{context.inspect}")
250
+ end
251
+
252
+ def track_tokens(response)
253
+ # Cursor doesn't provide token info, so this is a no-op
254
+ end
255
+
256
+ def handle_error(error, prompt:, options:)
257
+ classification = ErrorTaxonomy.classify(error, error_patterns)
258
+
259
+ log_error("send_message_error",
260
+ error: error.class.name,
261
+ message: error.message,
262
+ classification: classification)
263
+
264
+ case classification
265
+ when :rate_limited
266
+ raise RateLimitError.new(error.message, original_error: error)
267
+ when :auth_expired
268
+ raise AuthenticationError.new(error.message, original_error: error)
269
+ when :timeout
270
+ raise TimeoutError.new(error.message, original_error: error)
271
+ else
272
+ raise ProviderError.new(error.message, original_error: error)
273
+ end
274
+ end
275
+
276
+ def log_error(action, **context)
277
+ @logger&.error("[AgentHarness::Cursor] #{action}: #{context.inspect}")
278
+ end
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Providers
5
+ # Google Gemini CLI provider
6
+ #
7
+ # Provides integration with the Google Gemini CLI tool.
8
+ class Gemini < Base
9
+ # Model name pattern for Gemini models
10
+ MODEL_PATTERN = /^gemini-[\d.]+-(?:pro|flash|ultra)(?:-\d+)?$/i
11
+
12
+ class << self
13
+ def provider_name
14
+ :gemini
15
+ end
16
+
17
+ def binary_name
18
+ "gemini"
19
+ end
20
+
21
+ def available?
22
+ executor = AgentHarness.configuration.command_executor
23
+ !!executor.which(binary_name)
24
+ end
25
+
26
+ def firewall_requirements
27
+ {
28
+ domains: [
29
+ "generativelanguage.googleapis.com",
30
+ "oauth2.googleapis.com",
31
+ "accounts.google.com",
32
+ "www.googleapis.com"
33
+ ],
34
+ ip_ranges: []
35
+ }
36
+ end
37
+
38
+ def instruction_file_paths
39
+ [
40
+ {
41
+ path: "GEMINI.md",
42
+ description: "Google Gemini agent instructions",
43
+ symlink: true
44
+ }
45
+ ]
46
+ end
47
+
48
+ def discover_models
49
+ return [] unless available?
50
+
51
+ # Gemini CLI doesn't have a standard model listing command
52
+ # Return common models
53
+ [
54
+ {name: "gemini-2.0-flash", family: "gemini-2-0-flash", tier: "standard", provider: "gemini"},
55
+ {name: "gemini-2.5-pro", family: "gemini-2-5-pro", tier: "advanced", provider: "gemini"},
56
+ {name: "gemini-1.5-pro", family: "gemini-1-5-pro", tier: "standard", provider: "gemini"},
57
+ {name: "gemini-1.5-flash", family: "gemini-1-5-flash", tier: "mini", provider: "gemini"}
58
+ ]
59
+ end
60
+
61
+ def model_family(provider_model_name)
62
+ # Strip version suffix: "gemini-1.5-pro-001" -> "gemini-1.5-pro"
63
+ provider_model_name.sub(/-\d+$/, "")
64
+ end
65
+
66
+ def provider_model_name(family_name)
67
+ family_name
68
+ end
69
+
70
+ def supports_model_family?(family_name)
71
+ MODEL_PATTERN.match?(family_name) || family_name.start_with?("gemini-")
72
+ end
73
+ end
74
+
75
+ def name
76
+ "gemini"
77
+ end
78
+
79
+ def display_name
80
+ "Google Gemini"
81
+ end
82
+
83
+ def capabilities
84
+ {
85
+ streaming: true,
86
+ file_upload: true,
87
+ vision: true,
88
+ tool_use: true,
89
+ json_mode: true,
90
+ mcp: false,
91
+ dangerous_mode: false
92
+ }
93
+ end
94
+
95
+ def error_patterns
96
+ {
97
+ rate_limited: [
98
+ /rate.?limit/i,
99
+ /quota.?exceeded/i,
100
+ /429/
101
+ ],
102
+ auth_expired: [
103
+ /authentication/i,
104
+ /unauthorized/i,
105
+ /invalid.?credentials/i
106
+ ],
107
+ transient: [
108
+ /timeout/i,
109
+ /temporary/i,
110
+ /503/
111
+ ]
112
+ }
113
+ end
114
+
115
+ protected
116
+
117
+ def build_command(prompt, options)
118
+ cmd = [self.class.binary_name]
119
+
120
+ if @config.model && !@config.model.empty?
121
+ cmd += ["--model", @config.model]
122
+ end
123
+
124
+ cmd += @config.default_flags if @config.default_flags&.any?
125
+
126
+ cmd += ["--prompt", prompt]
127
+
128
+ cmd
129
+ end
130
+
131
+ def default_timeout
132
+ 300
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Providers
5
+ # GitHub Copilot CLI provider
6
+ #
7
+ # Provides integration with the GitHub Copilot CLI tool.
8
+ class GithubCopilot < Base
9
+ # Model name pattern for GitHub Copilot (uses OpenAI models)
10
+ MODEL_PATTERN = /^gpt-[\d.o-]+(?:-turbo)?(?:-mini)?$/i
11
+
12
+ class << self
13
+ def provider_name
14
+ :github_copilot
15
+ end
16
+
17
+ def binary_name
18
+ "copilot"
19
+ end
20
+
21
+ def available?
22
+ executor = AgentHarness.configuration.command_executor
23
+ !!executor.which(binary_name)
24
+ end
25
+
26
+ def firewall_requirements
27
+ {
28
+ domains: [
29
+ "copilot-proxy.githubusercontent.com",
30
+ "api.githubcopilot.com",
31
+ "copilot-telemetry.githubusercontent.com",
32
+ "default.exp-tas.com",
33
+ "copilot-completions.githubusercontent.com"
34
+ ],
35
+ ip_ranges: []
36
+ }
37
+ end
38
+
39
+ def instruction_file_paths
40
+ [
41
+ {
42
+ path: ".github/copilot-instructions.md",
43
+ description: "GitHub Copilot agent instructions",
44
+ symlink: true
45
+ }
46
+ ]
47
+ end
48
+
49
+ def discover_models
50
+ return [] unless available?
51
+
52
+ [
53
+ {name: "gpt-4o", family: "gpt-4o", tier: "standard", provider: "github_copilot"},
54
+ {name: "gpt-4o-mini", family: "gpt-4o-mini", tier: "mini", provider: "github_copilot"},
55
+ {name: "gpt-4-turbo", family: "gpt-4-turbo", tier: "advanced", provider: "github_copilot"}
56
+ ]
57
+ end
58
+
59
+ def model_family(provider_model_name)
60
+ provider_model_name
61
+ end
62
+
63
+ def provider_model_name(family_name)
64
+ family_name
65
+ end
66
+
67
+ def supports_model_family?(family_name)
68
+ MODEL_PATTERN.match?(family_name)
69
+ end
70
+ end
71
+
72
+ def name
73
+ "github_copilot"
74
+ end
75
+
76
+ def display_name
77
+ "GitHub Copilot CLI"
78
+ end
79
+
80
+ def capabilities
81
+ {
82
+ streaming: false,
83
+ file_upload: false,
84
+ vision: false,
85
+ tool_use: true,
86
+ json_mode: false,
87
+ mcp: false,
88
+ dangerous_mode: true
89
+ }
90
+ end
91
+
92
+ def supports_dangerous_mode?
93
+ true
94
+ end
95
+
96
+ def dangerous_mode_flags
97
+ ["--allow-all-tools"]
98
+ end
99
+
100
+ def supports_sessions?
101
+ true
102
+ end
103
+
104
+ def session_flags(session_id)
105
+ return [] unless session_id && !session_id.empty?
106
+ ["--resume", session_id]
107
+ end
108
+
109
+ def error_patterns
110
+ {
111
+ auth_expired: [
112
+ /not.?authorized/i,
113
+ /access.?denied/i,
114
+ /permission.?denied/i,
115
+ /not.?enabled/i,
116
+ /subscription.?required/i
117
+ ],
118
+ rate_limited: [
119
+ /usage.?limit/i,
120
+ /rate.?limit/i
121
+ ],
122
+ transient: [
123
+ /connection.?error/i,
124
+ /timeout/i,
125
+ /try.?again/i
126
+ ],
127
+ permanent: [
128
+ /invalid.?command/i,
129
+ /unknown.?flag/i
130
+ ]
131
+ }
132
+ end
133
+
134
+ protected
135
+
136
+ def build_command(prompt, options)
137
+ cmd = [self.class.binary_name, "-p", prompt]
138
+
139
+ # Add dangerous mode flags by default for automation
140
+ cmd += dangerous_mode_flags if supports_dangerous_mode?
141
+
142
+ # Add session support if provided
143
+ if options[:session] && !options[:session].empty?
144
+ cmd += session_flags(options[:session])
145
+ end
146
+
147
+ cmd
148
+ end
149
+
150
+ def default_timeout
151
+ 300
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Providers
5
+ # Kilocode CLI provider
6
+ #
7
+ # Provides integration with the Kilocode CLI tool.
8
+ class Kilocode < Base
9
+ class << self
10
+ def provider_name
11
+ :kilocode
12
+ end
13
+
14
+ def binary_name
15
+ "kilocode"
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
+ ip_ranges: []
27
+ }
28
+ end
29
+
30
+ def instruction_file_paths
31
+ []
32
+ end
33
+
34
+ def discover_models
35
+ return [] unless available?
36
+ []
37
+ end
38
+ end
39
+
40
+ def name
41
+ "kilocode"
42
+ end
43
+
44
+ def display_name
45
+ "Kilocode CLI"
46
+ end
47
+
48
+ def capabilities
49
+ {
50
+ streaming: false,
51
+ file_upload: false,
52
+ vision: false,
53
+ tool_use: false,
54
+ json_mode: false,
55
+ mcp: false,
56
+ dangerous_mode: false
57
+ }
58
+ end
59
+
60
+ protected
61
+
62
+ def build_command(prompt, options)
63
+ cmd = [self.class.binary_name]
64
+ cmd += ["--prompt", prompt]
65
+ cmd
66
+ end
67
+
68
+ def default_timeout
69
+ 300
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Providers
5
+ # OpenCode CLI provider
6
+ #
7
+ # Provides integration with the OpenCode CLI tool.
8
+ class Opencode < Base
9
+ class << self
10
+ def provider_name
11
+ :opencode
12
+ end
13
+
14
+ def binary_name
15
+ "opencode"
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
+ ],
28
+ ip_ranges: []
29
+ }
30
+ end
31
+
32
+ def instruction_file_paths
33
+ []
34
+ end
35
+
36
+ def discover_models
37
+ return [] unless available?
38
+ []
39
+ end
40
+ end
41
+
42
+ def name
43
+ "opencode"
44
+ end
45
+
46
+ def display_name
47
+ "OpenCode CLI"
48
+ end
49
+
50
+ def capabilities
51
+ {
52
+ streaming: false,
53
+ file_upload: false,
54
+ vision: false,
55
+ tool_use: false,
56
+ json_mode: false,
57
+ mcp: false,
58
+ dangerous_mode: false
59
+ }
60
+ end
61
+
62
+ protected
63
+
64
+ def build_command(prompt, options)
65
+ cmd = [self.class.binary_name]
66
+ cmd += ["--prompt", prompt]
67
+ cmd
68
+ end
69
+
70
+ def default_timeout
71
+ 300
72
+ end
73
+ end
74
+ end
75
+ end