agent-harness 0.5.2 → 0.5.4
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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/lib/agent_harness/errors.rb +14 -0
- data/lib/agent_harness/mcp_server.rb +157 -0
- data/lib/agent_harness/providers/adapter.rb +52 -0
- data/lib/agent_harness/providers/anthropic.rb +103 -0
- data/lib/agent_harness/providers/base.rb +38 -0
- data/lib/agent_harness/providers/codex.rb +118 -0
- data/lib/agent_harness/providers/cursor.rb +14 -0
- data/lib/agent_harness/providers/gemini.rb +116 -2
- data/lib/agent_harness/providers/mistral_vibe.rb +75 -0
- data/lib/agent_harness/providers/registry.rb +1 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ac511d4448bf777f9389cdba34c165a3b97ac607cfca1db90f0fd5c9de0a4af
|
|
4
|
+
data.tar.gz: 946efb4b7f13e36da7b4c3cc8c3efc3722421aa83ccaf9789efdaa4e776d4bc6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c3afe5167530f2cd4f8b435b5098732a12b3630f4324cbfa002f3983d760e3a41488ae0d565a0a0694d9b08704d74f010845fb39251a8f92782c6c6d01a572e
|
|
7
|
+
data.tar.gz: 8a9d9706b997b2c8543a45a43cdf476a089eaded7283fe6b3dade1394242f9786c7458b68a2953bcb866611d21a74a360a35d5b1ae3ade40ebe222c819d3c7ce
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.4](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.3...agent-harness/v0.5.4) (2026-03-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* 44: feat(mcp): add first-class MCP server configuration to request execution ([#45](https://github.com/viamin/agent-harness/issues/45)) ([454cd9b](https://github.com/viamin/agent-harness/commit/454cd9be1c4bcd2eb92a4ca6f81cc012d4ce1f8c))
|
|
9
|
+
|
|
10
|
+
## [0.5.3](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.2...agent-harness/v0.5.3) (2026-03-27)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* 41: Add provider-specific health/auth checks for Gemini and Codex ([#42](https://github.com/viamin/agent-harness/issues/42)) ([be95135](https://github.com/viamin/agent-harness/commit/be9513534e55aa3df9c0885b6e3580a3b146eb93))
|
|
16
|
+
|
|
3
17
|
## [0.5.2](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.1...agent-harness/v0.5.2) (2026-03-24)
|
|
4
18
|
|
|
5
19
|
|
data/lib/agent_harness/errors.rb
CHANGED
|
@@ -57,6 +57,20 @@ module AgentHarness
|
|
|
57
57
|
# Configuration errors
|
|
58
58
|
class ConfigurationError < Error; end
|
|
59
59
|
|
|
60
|
+
# MCP-specific errors
|
|
61
|
+
class McpConfigurationError < ConfigurationError; end
|
|
62
|
+
|
|
63
|
+
class McpUnsupportedError < ProviderError
|
|
64
|
+
attr_reader :provider
|
|
65
|
+
|
|
66
|
+
def initialize(message = nil, provider: nil, **kwargs)
|
|
67
|
+
@provider = provider
|
|
68
|
+
super(message, **kwargs)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class McpTransportUnsupportedError < McpUnsupportedError; end
|
|
73
|
+
|
|
60
74
|
# Orchestration errors
|
|
61
75
|
class NoProvidersAvailableError < Error
|
|
62
76
|
attr_reader :attempted_providers, :errors
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentHarness
|
|
4
|
+
# Canonical representation of an MCP server for request-time execution.
|
|
5
|
+
#
|
|
6
|
+
# Provider-agnostic value object that can be translated by each provider
|
|
7
|
+
# adapter into its CLI-specific configuration.
|
|
8
|
+
#
|
|
9
|
+
# @example stdio server
|
|
10
|
+
# McpServer.new(
|
|
11
|
+
# name: "filesystem",
|
|
12
|
+
# transport: "stdio",
|
|
13
|
+
# command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
|
|
14
|
+
# env: { "DEBUG" => "0" }
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# @example HTTP/URL server
|
|
18
|
+
# McpServer.new(
|
|
19
|
+
# name: "playwright",
|
|
20
|
+
# transport: "http",
|
|
21
|
+
# url: "http://mcp-playwright:3000/mcp"
|
|
22
|
+
# )
|
|
23
|
+
class McpServer
|
|
24
|
+
VALID_TRANSPORTS = %w[stdio http sse].freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :name, :transport, :command, :args, :env, :url
|
|
27
|
+
|
|
28
|
+
# @param name [String] unique name for this MCP server
|
|
29
|
+
# @param transport [String] one of "stdio", "http", "sse"
|
|
30
|
+
# @param command [Array<String>, nil] command to launch (stdio only)
|
|
31
|
+
# @param args [Array<String>, nil] additional args for the command
|
|
32
|
+
# @param env [Hash<String,String>, nil] environment variables for the server process
|
|
33
|
+
# @param url [String, nil] URL for HTTP/SSE transport
|
|
34
|
+
def initialize(name:, transport:, command: nil, args: nil, env: nil, url: nil)
|
|
35
|
+
@name = name
|
|
36
|
+
@transport = transport.to_s
|
|
37
|
+
@command = command
|
|
38
|
+
@args = args || []
|
|
39
|
+
@env = env || {}
|
|
40
|
+
@url = url
|
|
41
|
+
|
|
42
|
+
validate!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Build from a plain Hash (e.g. from user input or serialized config)
|
|
46
|
+
#
|
|
47
|
+
# @param hash [Hash] server definition
|
|
48
|
+
# @return [McpServer]
|
|
49
|
+
def self.from_hash(hash)
|
|
50
|
+
unless hash.is_a?(Hash)
|
|
51
|
+
raise McpConfigurationError, "MCP server definition must be a Hash, got #{hash.class}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
hash = hash.transform_keys(&:to_sym)
|
|
56
|
+
rescue NoMethodError, TypeError => e
|
|
57
|
+
raise McpConfigurationError, "MCP server hash contains invalid keys: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
new(
|
|
61
|
+
name: hash[:name],
|
|
62
|
+
transport: hash[:transport],
|
|
63
|
+
command: hash[:command],
|
|
64
|
+
args: hash[:args],
|
|
65
|
+
env: hash[:env],
|
|
66
|
+
url: hash[:url]
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def stdio?
|
|
71
|
+
@transport == "stdio"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def http?
|
|
75
|
+
%w[http sse].include?(@transport)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def to_h
|
|
79
|
+
h = {name: @name, transport: @transport}
|
|
80
|
+
if stdio?
|
|
81
|
+
h[:command] = @command
|
|
82
|
+
h[:args] = @args unless @args.empty?
|
|
83
|
+
else
|
|
84
|
+
h[:url] = @url
|
|
85
|
+
end
|
|
86
|
+
h[:env] = @env unless @env.empty?
|
|
87
|
+
h
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def validate!
|
|
93
|
+
raise McpConfigurationError, "MCP server name is required" if @name.nil? || @name.to_s.strip.empty?
|
|
94
|
+
|
|
95
|
+
unless VALID_TRANSPORTS.include?(@transport)
|
|
96
|
+
raise McpConfigurationError,
|
|
97
|
+
"Invalid MCP transport '#{@transport}' for server '#{@name}'. Valid transports: #{VALID_TRANSPORTS.join(", ")}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
validate_args!
|
|
101
|
+
validate_env!
|
|
102
|
+
validate_stdio! if stdio?
|
|
103
|
+
validate_http! if http?
|
|
104
|
+
validate_no_stdio_only_fields_on_http! if http?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_args!
|
|
108
|
+
return if @args.is_a?(Array) && @args.all? { |a| a.is_a?(String) }
|
|
109
|
+
|
|
110
|
+
raise McpConfigurationError,
|
|
111
|
+
"MCP server '#{@name}' args must be an Array of Strings"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_env!
|
|
115
|
+
return if @env.is_a?(Hash) && @env.keys.all? { |k| k.is_a?(String) } && @env.values.all? { |v| v.is_a?(String) }
|
|
116
|
+
|
|
117
|
+
raise McpConfigurationError,
|
|
118
|
+
"MCP server '#{@name}' env must be a Hash with String keys and values"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_stdio!
|
|
122
|
+
if @command.nil? || !@command.is_a?(Array) || @command.empty?
|
|
123
|
+
raise McpConfigurationError,
|
|
124
|
+
"MCP server '#{@name}' with stdio transport requires a non-empty command array"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
unless @command.all? { |c| c.is_a?(String) }
|
|
128
|
+
raise McpConfigurationError,
|
|
129
|
+
"MCP server '#{@name}' command must contain only strings"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
return if @url.nil?
|
|
133
|
+
|
|
134
|
+
raise McpConfigurationError,
|
|
135
|
+
"MCP server '#{@name}' with stdio transport should not have a url"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_http!
|
|
139
|
+
if @url.nil? || @url.to_s.strip.empty?
|
|
140
|
+
raise McpConfigurationError,
|
|
141
|
+
"MCP server '#{@name}' with #{@transport} transport requires a url"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
return if @command.nil?
|
|
145
|
+
|
|
146
|
+
raise McpConfigurationError,
|
|
147
|
+
"MCP server '#{@name}' with #{@transport} transport should not have a command"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def validate_no_stdio_only_fields_on_http!
|
|
151
|
+
return if @args.empty?
|
|
152
|
+
|
|
153
|
+
raise McpConfigurationError,
|
|
154
|
+
"MCP server '#{@name}' with #{@transport} transport should not have args (args are only valid for stdio)"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -124,6 +124,58 @@ module AgentHarness
|
|
|
124
124
|
[]
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
# Supported MCP transport types for this provider
|
|
128
|
+
#
|
|
129
|
+
# @return [Array<String>] supported transports (e.g. ["stdio", "http"])
|
|
130
|
+
def supported_mcp_transports
|
|
131
|
+
[]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Build provider-specific MCP flags/arguments for CLI invocation
|
|
135
|
+
#
|
|
136
|
+
# @param mcp_servers [Array<McpServer>] MCP server definitions
|
|
137
|
+
# @param working_dir [String, nil] working directory for temp files
|
|
138
|
+
# @return [Array<String>] CLI flags to append to the command
|
|
139
|
+
def build_mcp_flags(mcp_servers, working_dir: nil)
|
|
140
|
+
[]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Validate that this provider can handle the given MCP servers
|
|
144
|
+
#
|
|
145
|
+
# @param mcp_servers [Array<McpServer>] MCP server definitions
|
|
146
|
+
# @raise [McpUnsupportedError] if MCP is not supported
|
|
147
|
+
# @raise [McpTransportUnsupportedError] if a transport is not supported
|
|
148
|
+
def validate_mcp_servers!(mcp_servers)
|
|
149
|
+
return if mcp_servers.nil? || mcp_servers.empty?
|
|
150
|
+
|
|
151
|
+
unless supports_mcp?
|
|
152
|
+
raise McpUnsupportedError.new(
|
|
153
|
+
"Provider '#{self.class.provider_name}' does not support MCP servers",
|
|
154
|
+
provider: self.class.provider_name
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
supported = supported_mcp_transports
|
|
159
|
+
|
|
160
|
+
if supported.empty?
|
|
161
|
+
raise McpUnsupportedError.new(
|
|
162
|
+
"Provider '#{self.class.provider_name}' does not support request-time MCP servers",
|
|
163
|
+
provider: self.class.provider_name
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
mcp_servers.each do |server|
|
|
168
|
+
next if supported.include?(server.transport)
|
|
169
|
+
|
|
170
|
+
raise McpTransportUnsupportedError.new(
|
|
171
|
+
"Provider '#{self.class.provider_name}' does not support MCP transport " \
|
|
172
|
+
"'#{server.transport}' (server: '#{server.name}'). " \
|
|
173
|
+
"Supported transports: #{supported.join(", ")}",
|
|
174
|
+
provider: self.class.provider_name
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
127
179
|
# Check if provider supports dangerous mode
|
|
128
180
|
#
|
|
129
181
|
# @return [Boolean] true if dangerous mode is supported
|
|
@@ -172,10 +172,27 @@ module AgentHarness
|
|
|
172
172
|
}
|
|
173
173
|
end
|
|
174
174
|
|
|
175
|
+
def send_message(prompt:, **options)
|
|
176
|
+
super
|
|
177
|
+
ensure
|
|
178
|
+
cleanup_mcp_tempfiles!
|
|
179
|
+
end
|
|
180
|
+
|
|
175
181
|
def supports_mcp?
|
|
176
182
|
true
|
|
177
183
|
end
|
|
178
184
|
|
|
185
|
+
def supported_mcp_transports
|
|
186
|
+
%w[stdio http sse]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def build_mcp_flags(mcp_servers, working_dir: nil)
|
|
190
|
+
return [] if mcp_servers.empty?
|
|
191
|
+
|
|
192
|
+
config_path = write_mcp_config_file(mcp_servers, working_dir: working_dir)
|
|
193
|
+
["--mcp-config", config_path]
|
|
194
|
+
end
|
|
195
|
+
|
|
179
196
|
def supports_dangerous_mode?
|
|
180
197
|
true
|
|
181
198
|
end
|
|
@@ -266,6 +283,11 @@ module AgentHarness
|
|
|
266
283
|
cmd += dangerous_mode_flags
|
|
267
284
|
end
|
|
268
285
|
|
|
286
|
+
# Add MCP server flags (validated/normalized by Base#send_message)
|
|
287
|
+
if options[:mcp_servers]&.any?
|
|
288
|
+
cmd += build_mcp_flags(options[:mcp_servers])
|
|
289
|
+
end
|
|
290
|
+
|
|
269
291
|
# Add custom flags from config
|
|
270
292
|
cmd += @config.default_flags if @config.default_flags&.any?
|
|
271
293
|
|
|
@@ -376,6 +398,87 @@ module AgentHarness
|
|
|
376
398
|
servers
|
|
377
399
|
end
|
|
378
400
|
|
|
401
|
+
def write_mcp_config_file(mcp_servers, working_dir: nil)
|
|
402
|
+
require "tempfile"
|
|
403
|
+
require "tmpdir"
|
|
404
|
+
require "securerandom"
|
|
405
|
+
|
|
406
|
+
config = build_claude_mcp_config(mcp_servers)
|
|
407
|
+
config_json = JSON.generate(config)
|
|
408
|
+
|
|
409
|
+
if @executor.is_a?(DockerCommandExecutor)
|
|
410
|
+
# When running inside a Docker container, write the config file
|
|
411
|
+
# inside the container so the CLI process can read it.
|
|
412
|
+
# Track the path so cleanup_mcp_tempfiles! can remove it after execution.
|
|
413
|
+
container_path = "/tmp/agent_harness_mcp_#{SecureRandom.hex(8)}.json"
|
|
414
|
+
result = @executor.execute(
|
|
415
|
+
["sh", "-c", "cat > #{container_path}"],
|
|
416
|
+
stdin_data: config_json,
|
|
417
|
+
timeout: 5
|
|
418
|
+
)
|
|
419
|
+
unless result.success?
|
|
420
|
+
raise McpConfigurationError,
|
|
421
|
+
"Failed to write MCP config inside container: #{result.stderr}"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
@mcp_docker_config_paths ||= []
|
|
425
|
+
@mcp_docker_config_paths << container_path
|
|
426
|
+
|
|
427
|
+
container_path
|
|
428
|
+
else
|
|
429
|
+
dir = working_dir || Dir.tmpdir
|
|
430
|
+
file = Tempfile.new(["agent_harness_mcp_", ".json"], dir)
|
|
431
|
+
file.write(config_json)
|
|
432
|
+
file.close
|
|
433
|
+
|
|
434
|
+
# Hold a reference so the Tempfile is not garbage-collected (and
|
|
435
|
+
# therefore deleted) before the CLI process reads it.
|
|
436
|
+
# Cleaned up by cleanup_mcp_tempfiles! after execution.
|
|
437
|
+
@mcp_config_tempfiles ||= []
|
|
438
|
+
@mcp_config_tempfiles << file
|
|
439
|
+
|
|
440
|
+
file.path
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def build_claude_mcp_config(mcp_servers)
|
|
445
|
+
servers = {}
|
|
446
|
+
mcp_servers.each do |server|
|
|
447
|
+
h = if server.stdio?
|
|
448
|
+
entry = {command: server.command.first}
|
|
449
|
+
remaining_args = server.command[1..] + server.args
|
|
450
|
+
entry[:args] = remaining_args unless remaining_args.empty?
|
|
451
|
+
entry
|
|
452
|
+
else
|
|
453
|
+
{url: server.url}
|
|
454
|
+
end
|
|
455
|
+
h[:env] = server.env unless server.env.empty?
|
|
456
|
+
servers[server.name] = h
|
|
457
|
+
end
|
|
458
|
+
{mcpServers: servers}
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def cleanup_mcp_tempfiles!
|
|
462
|
+
if @mcp_config_tempfiles
|
|
463
|
+
@mcp_config_tempfiles.each do |file|
|
|
464
|
+
file.close unless file.closed?
|
|
465
|
+
file.unlink
|
|
466
|
+
rescue
|
|
467
|
+
nil
|
|
468
|
+
end
|
|
469
|
+
@mcp_config_tempfiles = nil
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
if @mcp_docker_config_paths
|
|
473
|
+
@mcp_docker_config_paths.each do |path|
|
|
474
|
+
@executor.execute(["rm", "-f", path], timeout: 5)
|
|
475
|
+
rescue
|
|
476
|
+
nil
|
|
477
|
+
end
|
|
478
|
+
@mcp_docker_config_paths = nil
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
379
482
|
def log_debug(action, **context)
|
|
380
483
|
@logger&.debug("[AgentHarness::Anthropic] #{action}: #{context.inspect}")
|
|
381
484
|
end
|
|
@@ -63,6 +63,10 @@ module AgentHarness
|
|
|
63
63
|
def send_message(prompt:, **options)
|
|
64
64
|
log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
|
|
65
65
|
|
|
66
|
+
# Normalize and validate MCP servers
|
|
67
|
+
options = normalize_mcp_servers(options)
|
|
68
|
+
validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
|
|
69
|
+
|
|
66
70
|
# Build command
|
|
67
71
|
command = build_command(prompt, options)
|
|
68
72
|
|
|
@@ -83,6 +87,8 @@ module AgentHarness
|
|
|
83
87
|
log_debug("send_message_complete", duration: duration, tokens: response.tokens)
|
|
84
88
|
|
|
85
89
|
response
|
|
90
|
+
rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
91
|
+
raise
|
|
86
92
|
rescue => e
|
|
87
93
|
handle_error(e, prompt: prompt, options: options)
|
|
88
94
|
end
|
|
@@ -145,6 +151,38 @@ module AgentHarness
|
|
|
145
151
|
|
|
146
152
|
private
|
|
147
153
|
|
|
154
|
+
def normalize_mcp_servers(options)
|
|
155
|
+
servers = options[:mcp_servers]
|
|
156
|
+
return options if servers.nil?
|
|
157
|
+
|
|
158
|
+
unless servers.is_a?(Array)
|
|
159
|
+
raise McpConfigurationError,
|
|
160
|
+
"mcp_servers must be an Array of Hash or McpServer, got #{servers.class}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
return options if servers.empty?
|
|
164
|
+
|
|
165
|
+
normalized = servers.map do |server|
|
|
166
|
+
if server.is_a?(McpServer)
|
|
167
|
+
server
|
|
168
|
+
elsif server.is_a?(Hash)
|
|
169
|
+
McpServer.from_hash(server)
|
|
170
|
+
else
|
|
171
|
+
raise McpConfigurationError, "MCP server must be a Hash or McpServer, got #{server.class}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Ensure MCP server names are unique to avoid silent overwrites downstream
|
|
176
|
+
names = normalized.map(&:name)
|
|
177
|
+
duplicate_names = names.group_by { |n| n }.select { |_, v| v.size > 1 }.keys
|
|
178
|
+
unless duplicate_names.empty?
|
|
179
|
+
raise McpConfigurationError,
|
|
180
|
+
"Duplicate MCP server names detected: #{duplicate_names.join(", ")}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
options.merge(mcp_servers: normalized)
|
|
184
|
+
end
|
|
185
|
+
|
|
148
186
|
def execute_with_timeout(command, timeout:, env:)
|
|
149
187
|
@executor.execute(command, timeout: timeout, env: env)
|
|
150
188
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module AgentHarness
|
|
4
6
|
module Providers
|
|
5
7
|
# OpenAI Codex CLI provider
|
|
@@ -78,11 +80,104 @@ module AgentHarness
|
|
|
78
80
|
["--session", session_id]
|
|
79
81
|
end
|
|
80
82
|
|
|
83
|
+
def error_patterns
|
|
84
|
+
{
|
|
85
|
+
rate_limited: [
|
|
86
|
+
/rate.?limit/i,
|
|
87
|
+
/too.?many.?requests/i,
|
|
88
|
+
/429/
|
|
89
|
+
],
|
|
90
|
+
auth_expired: [
|
|
91
|
+
/invalid.*api.*key/i,
|
|
92
|
+
/unauthorized/i,
|
|
93
|
+
/authentication/i,
|
|
94
|
+
/401/,
|
|
95
|
+
/incorrect.*api.*key/i
|
|
96
|
+
],
|
|
97
|
+
quota_exceeded: [
|
|
98
|
+
/quota.*exceeded/i,
|
|
99
|
+
/insufficient.*quota/i,
|
|
100
|
+
/billing/i
|
|
101
|
+
],
|
|
102
|
+
transient: [
|
|
103
|
+
/timeout/i,
|
|
104
|
+
/connection.*reset/i,
|
|
105
|
+
/service.*unavailable/i,
|
|
106
|
+
/503/,
|
|
107
|
+
/502/
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def auth_status
|
|
113
|
+
api_key = ENV["OPENAI_API_KEY"]
|
|
114
|
+
if api_key && !api_key.strip.empty?
|
|
115
|
+
if api_key.strip.start_with?("sk-")
|
|
116
|
+
return {valid: true, expires_at: nil, error: nil, auth_method: :api_key}
|
|
117
|
+
else
|
|
118
|
+
return {valid: false, expires_at: nil, error: "OPENAI_API_KEY is set but does not appear to be a valid OpenAI API key"}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
credentials = read_codex_credentials
|
|
123
|
+
if credentials
|
|
124
|
+
key = credentials["api_key"] || credentials["apiKey"] || credentials["OPENAI_API_KEY"]
|
|
125
|
+
if key.is_a?(String) && !key.strip.empty?
|
|
126
|
+
if key.strip.start_with?("sk-")
|
|
127
|
+
return {valid: true, expires_at: nil, error: nil, auth_method: :config_file}
|
|
128
|
+
else
|
|
129
|
+
return {valid: false, expires_at: nil, error: "Config file API key is set but does not appear to be a valid OpenAI API key"}
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
{valid: false, expires_at: nil, error: "No OpenAI API key found. Set OPENAI_API_KEY or configure in #{codex_config_path}"}
|
|
135
|
+
rescue IOError, JSON::ParserError => e
|
|
136
|
+
{valid: false, expires_at: nil, error: e.message}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def health_status
|
|
140
|
+
unless self.class.available?
|
|
141
|
+
return {healthy: false, message: "Codex CLI not found in PATH. Install from https://github.com/openai/codex"}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
auth = auth_status
|
|
145
|
+
unless auth[:valid]
|
|
146
|
+
return {healthy: false, message: auth[:error]}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
{healthy: true, message: "Codex CLI available and authenticated"}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_config
|
|
153
|
+
errors = []
|
|
154
|
+
|
|
155
|
+
flags = @config.default_flags
|
|
156
|
+
unless flags.nil?
|
|
157
|
+
if flags.is_a?(Array)
|
|
158
|
+
invalid = flags.reject { |f| f.is_a?(String) }
|
|
159
|
+
errors << "default_flags contains non-string values" if invalid.any?
|
|
160
|
+
else
|
|
161
|
+
errors << "default_flags must be an array of strings"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
{valid: errors.empty?, errors: errors}
|
|
166
|
+
end
|
|
167
|
+
|
|
81
168
|
protected
|
|
82
169
|
|
|
83
170
|
def build_command(prompt, options)
|
|
84
171
|
cmd = [self.class.binary_name, "exec"]
|
|
85
172
|
|
|
173
|
+
flags = @config.default_flags
|
|
174
|
+
if flags
|
|
175
|
+
unless flags.is_a?(Array)
|
|
176
|
+
raise ArgumentError, "Codex configuration error: default_flags must be an array of strings"
|
|
177
|
+
end
|
|
178
|
+
cmd += flags if flags.any?
|
|
179
|
+
end
|
|
180
|
+
|
|
86
181
|
if options[:session]
|
|
87
182
|
cmd += session_flags(options[:session])
|
|
88
183
|
end
|
|
@@ -95,6 +190,29 @@ module AgentHarness
|
|
|
95
190
|
def default_timeout
|
|
96
191
|
300
|
|
97
192
|
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def read_codex_credentials
|
|
197
|
+
path = codex_config_path
|
|
198
|
+
return nil unless File.exist?(path)
|
|
199
|
+
|
|
200
|
+
parsed = JSON.parse(File.read(path))
|
|
201
|
+
return nil unless parsed.is_a?(Hash)
|
|
202
|
+
|
|
203
|
+
parsed
|
|
204
|
+
rescue Errno::ENOENT
|
|
205
|
+
nil
|
|
206
|
+
rescue Errno::EACCES => e
|
|
207
|
+
raise IOError, "Permission denied reading Codex config at #{path}: #{e.message}"
|
|
208
|
+
rescue JSON::ParserError
|
|
209
|
+
raise JSON::ParserError, "Invalid JSON in Codex config at #{path}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def codex_config_path
|
|
213
|
+
config_dir = ENV["CODEX_CONFIG_DIR"] || File.expand_path("~/.codex")
|
|
214
|
+
File.join(config_dir, "config.json")
|
|
215
|
+
end
|
|
98
216
|
end
|
|
99
217
|
end
|
|
100
218
|
end
|
|
@@ -109,6 +109,14 @@ module AgentHarness
|
|
|
109
109
|
true
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# Cursor supports MCP for fetching existing server configurations (via
|
|
113
|
+
# fetch_mcp_servers) but does not support injecting request-time MCP
|
|
114
|
+
# servers into CLI invocations. Returning an empty list causes
|
|
115
|
+
# validate_mcp_servers! to raise McpUnsupportedError with a clear message.
|
|
116
|
+
def supported_mcp_transports
|
|
117
|
+
[]
|
|
118
|
+
end
|
|
119
|
+
|
|
112
120
|
def fetch_mcp_servers
|
|
113
121
|
# Try CLI first, then config file
|
|
114
122
|
fetch_mcp_servers_cli || fetch_mcp_servers_config
|
|
@@ -142,6 +150,10 @@ module AgentHarness
|
|
|
142
150
|
def send_message(prompt:, **options)
|
|
143
151
|
log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
|
|
144
152
|
|
|
153
|
+
# Normalize and validate MCP servers (same as Base#send_message)
|
|
154
|
+
options = normalize_mcp_servers(options)
|
|
155
|
+
validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
|
|
156
|
+
|
|
145
157
|
# Build command (without prompt in args - we send via stdin)
|
|
146
158
|
command = [self.class.binary_name, "-p"]
|
|
147
159
|
|
|
@@ -162,6 +174,8 @@ module AgentHarness
|
|
|
162
174
|
log_debug("send_message_complete", duration: duration)
|
|
163
175
|
|
|
164
176
|
response
|
|
177
|
+
rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
178
|
+
raise
|
|
165
179
|
rescue => e
|
|
166
180
|
handle_error(e, prompt: prompt, options: options)
|
|
167
181
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
3
6
|
module AgentHarness
|
|
4
7
|
module Providers
|
|
5
8
|
# Google Gemini CLI provider
|
|
@@ -106,7 +109,11 @@ module AgentHarness
|
|
|
106
109
|
auth_expired: [
|
|
107
110
|
/authentication/i,
|
|
108
111
|
/unauthorized/i,
|
|
109
|
-
/invalid.?credentials/i
|
|
112
|
+
/invalid.?credentials/i,
|
|
113
|
+
/login.*required/i,
|
|
114
|
+
/not.*logged.*in/i,
|
|
115
|
+
/credentials.*expired/i,
|
|
116
|
+
/account.*not.*verified/i
|
|
110
117
|
],
|
|
111
118
|
transient: [
|
|
112
119
|
/timeout/i,
|
|
@@ -116,6 +123,68 @@ module AgentHarness
|
|
|
116
123
|
}
|
|
117
124
|
end
|
|
118
125
|
|
|
126
|
+
def auth_status
|
|
127
|
+
api_key = [ENV["GEMINI_API_KEY"], ENV["GOOGLE_API_KEY"]].find { |key| key && !key.strip.empty? }
|
|
128
|
+
if api_key
|
|
129
|
+
return {valid: true, expires_at: nil, error: nil, auth_method: :api_key}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
credentials = read_gemini_credentials
|
|
133
|
+
return {valid: false, expires_at: nil, error: "No Gemini credentials found. Run 'gemini auth login' or set GEMINI_API_KEY or GOOGLE_API_KEY"} unless credentials
|
|
134
|
+
|
|
135
|
+
token = credentials["access_token"] || credentials["oauth_token"]
|
|
136
|
+
unless token.is_a?(String) && !token.strip.empty?
|
|
137
|
+
return {valid: false, expires_at: nil, error: "No authentication token in Gemini credentials"}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
expires_at = parse_gemini_expiry(credentials)
|
|
141
|
+
if expires_at && expires_at < Time.now
|
|
142
|
+
{valid: false, expires_at: expires_at, error: "Gemini session expired. Run 'gemini auth login' to re-authenticate"}
|
|
143
|
+
else
|
|
144
|
+
{valid: true, expires_at: expires_at, error: nil, auth_method: :oauth}
|
|
145
|
+
end
|
|
146
|
+
rescue IOError, JSON::ParserError => e
|
|
147
|
+
{valid: false, expires_at: nil, error: e.message}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def health_status
|
|
151
|
+
unless self.class.available?
|
|
152
|
+
return {healthy: false, message: "Gemini CLI not found in PATH. Install from https://github.com/google-gemini/gemini-cli"}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
auth = auth_status
|
|
156
|
+
unless auth[:valid]
|
|
157
|
+
return {healthy: false, message: auth[:error]}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
{healthy: true, message: "Gemini CLI available and authenticated"}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_config
|
|
164
|
+
errors = []
|
|
165
|
+
|
|
166
|
+
model = @config.model
|
|
167
|
+
if !model.nil? && !model.is_a?(String)
|
|
168
|
+
errors << "model must be a string"
|
|
169
|
+
elsif model.is_a?(String) && !model.empty?
|
|
170
|
+
unless self.class.supports_model_family?(model)
|
|
171
|
+
errors << "Unrecognized model '#{model}'. Expected a Gemini model (e.g., gemini-2.0-flash, gemini-2.5-pro)"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
flags = @config.default_flags
|
|
176
|
+
unless flags.nil?
|
|
177
|
+
if flags.is_a?(Array)
|
|
178
|
+
invalid = flags.reject { |f| f.is_a?(String) }
|
|
179
|
+
errors << "default_flags contains non-string values" if invalid.any?
|
|
180
|
+
else
|
|
181
|
+
errors << "default_flags must be an array of strings"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
{valid: errors.empty?, errors: errors}
|
|
186
|
+
end
|
|
187
|
+
|
|
119
188
|
protected
|
|
120
189
|
|
|
121
190
|
def build_command(prompt, options)
|
|
@@ -125,7 +194,13 @@ module AgentHarness
|
|
|
125
194
|
cmd += ["--model", @config.model]
|
|
126
195
|
end
|
|
127
196
|
|
|
128
|
-
|
|
197
|
+
flags = @config.default_flags
|
|
198
|
+
if flags
|
|
199
|
+
unless flags.is_a?(Array)
|
|
200
|
+
raise ArgumentError, "Gemini configuration error: default_flags must be an array of strings"
|
|
201
|
+
end
|
|
202
|
+
cmd += flags if flags.any?
|
|
203
|
+
end
|
|
129
204
|
|
|
130
205
|
cmd += ["--prompt", prompt]
|
|
131
206
|
|
|
@@ -135,6 +210,45 @@ module AgentHarness
|
|
|
135
210
|
def default_timeout
|
|
136
211
|
300
|
|
137
212
|
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def read_gemini_credentials
|
|
217
|
+
path = gemini_credentials_path
|
|
218
|
+
return nil unless File.exist?(path)
|
|
219
|
+
|
|
220
|
+
parsed = JSON.parse(File.read(path))
|
|
221
|
+
return nil unless parsed.is_a?(Hash)
|
|
222
|
+
|
|
223
|
+
parsed
|
|
224
|
+
rescue Errno::ENOENT
|
|
225
|
+
nil
|
|
226
|
+
rescue Errno::EACCES => e
|
|
227
|
+
raise IOError, "Permission denied reading Gemini credentials at #{path}: #{e.message}"
|
|
228
|
+
rescue JSON::ParserError
|
|
229
|
+
raise JSON::ParserError, "Invalid JSON in Gemini credentials at #{path}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def gemini_credentials_path
|
|
233
|
+
config_dir = ENV["GEMINI_CONFIG_DIR"] || File.expand_path("~/.gemini")
|
|
234
|
+
File.join(config_dir, "credentials.json")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def parse_gemini_expiry(credentials)
|
|
238
|
+
value = credentials["expires_at"] || credentials["expiresAt"] || credentials["expiry"]
|
|
239
|
+
return nil unless value
|
|
240
|
+
|
|
241
|
+
case value
|
|
242
|
+
when Time
|
|
243
|
+
value
|
|
244
|
+
when Integer, Float
|
|
245
|
+
Time.at(value)
|
|
246
|
+
when String
|
|
247
|
+
Time.parse(value)
|
|
248
|
+
end
|
|
249
|
+
rescue ArgumentError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
138
252
|
end
|
|
139
253
|
end
|
|
140
254
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Mistral Vibe CLI provider
|
|
6
|
+
#
|
|
7
|
+
# Provides integration with the Mistral Vibe CLI agent tool.
|
|
8
|
+
class MistralVibe < Base
|
|
9
|
+
class << self
|
|
10
|
+
def provider_name
|
|
11
|
+
:mistral_vibe
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def binary_name
|
|
15
|
+
"mistral-vibe"
|
|
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.mistral.ai"
|
|
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
|
+
"mistral_vibe"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def display_name
|
|
47
|
+
"Mistral Vibe 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, "run"]
|
|
66
|
+
cmd << prompt
|
|
67
|
+
cmd
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def default_timeout
|
|
71
|
+
300
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -123,6 +123,7 @@ module AgentHarness
|
|
|
123
123
|
register_if_available(:opencode, "agent_harness/providers/opencode", :Opencode)
|
|
124
124
|
register_if_available(:kilocode, "agent_harness/providers/kilocode", :Kilocode)
|
|
125
125
|
register_if_available(:aider, "agent_harness/providers/aider", :Aider)
|
|
126
|
+
register_if_available(:mistral_vibe, "agent_harness/providers/mistral_vibe", :MistralVibe)
|
|
126
127
|
end
|
|
127
128
|
|
|
128
129
|
def register_if_available(name, require_path, class_name, aliases: [])
|
data/lib/agent_harness.rb
CHANGED
|
@@ -137,6 +137,7 @@ end
|
|
|
137
137
|
|
|
138
138
|
# Core components
|
|
139
139
|
require_relative "agent_harness/errors"
|
|
140
|
+
require_relative "agent_harness/mcp_server"
|
|
140
141
|
require_relative "agent_harness/configuration"
|
|
141
142
|
require_relative "agent_harness/command_executor"
|
|
142
143
|
require_relative "agent_harness/docker_command_executor"
|
|
@@ -157,6 +158,7 @@ require_relative "agent_harness/providers/cursor"
|
|
|
157
158
|
require_relative "agent_harness/providers/gemini"
|
|
158
159
|
require_relative "agent_harness/providers/github_copilot"
|
|
159
160
|
require_relative "agent_harness/providers/kilocode"
|
|
161
|
+
require_relative "agent_harness/providers/mistral_vibe"
|
|
160
162
|
require_relative "agent_harness/providers/opencode"
|
|
161
163
|
|
|
162
164
|
# Orchestration layer
|
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.
|
|
4
|
+
version: 0.5.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bart Agapinan
|
|
@@ -84,6 +84,7 @@ files:
|
|
|
84
84
|
- lib/agent_harness/docker_command_executor.rb
|
|
85
85
|
- lib/agent_harness/error_taxonomy.rb
|
|
86
86
|
- lib/agent_harness/errors.rb
|
|
87
|
+
- lib/agent_harness/mcp_server.rb
|
|
87
88
|
- lib/agent_harness/orchestration/circuit_breaker.rb
|
|
88
89
|
- lib/agent_harness/orchestration/conductor.rb
|
|
89
90
|
- lib/agent_harness/orchestration/health_monitor.rb
|
|
@@ -100,6 +101,7 @@ files:
|
|
|
100
101
|
- lib/agent_harness/providers/gemini.rb
|
|
101
102
|
- lib/agent_harness/providers/github_copilot.rb
|
|
102
103
|
- lib/agent_harness/providers/kilocode.rb
|
|
104
|
+
- lib/agent_harness/providers/mistral_vibe.rb
|
|
103
105
|
- lib/agent_harness/providers/opencode.rb
|
|
104
106
|
- lib/agent_harness/providers/registry.rb
|
|
105
107
|
- lib/agent_harness/response.rb
|