agent-harness 0.11.1 → 0.11.3
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/configuration.rb +68 -1
- data/lib/agent_harness/errors.rb +11 -0
- data/lib/agent_harness/extensions.rb +644 -0
- data/lib/agent_harness/mcp_config_loader.rb +62 -0
- data/lib/agent_harness/mcp_config_translator.rb +85 -0
- data/lib/agent_harness/mcp_server.rb +40 -14
- data/lib/agent_harness/providers/anthropic.rb +3 -79
- data/lib/agent_harness/providers/base.rb +212 -9
- data/lib/agent_harness/providers/codex.rb +32 -1
- data/lib/agent_harness/providers/mcp_config_file_support.rb +76 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +45 -0
- metadata +5 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentHarness
|
|
4
|
+
module McpConfigTranslator
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def for_provider(provider, mcp_servers)
|
|
8
|
+
servers = normalize_servers(mcp_servers)
|
|
9
|
+
|
|
10
|
+
case provider.to_sym
|
|
11
|
+
when :anthropic, :claude, :claude_code
|
|
12
|
+
translate_for_claude(servers)
|
|
13
|
+
when :codex
|
|
14
|
+
translate_for_codex(servers)
|
|
15
|
+
when :openai
|
|
16
|
+
translate_for_openai(servers)
|
|
17
|
+
else
|
|
18
|
+
servers.map(&:to_h)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def normalize_servers(mcp_servers)
|
|
23
|
+
Array(mcp_servers).map do |server|
|
|
24
|
+
case server
|
|
25
|
+
when McpServer
|
|
26
|
+
server
|
|
27
|
+
when Hash
|
|
28
|
+
McpServer.from_hash(server)
|
|
29
|
+
else
|
|
30
|
+
raise McpConfigurationError, "MCP server must be a Hash or McpServer, got #{server.class}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def translate_for_claude(mcp_servers)
|
|
36
|
+
{
|
|
37
|
+
mcpServers: mcp_servers.each_with_object({}) do |server, memo|
|
|
38
|
+
entry = server.stdio? ? {command: server.command} : {url: server.url}
|
|
39
|
+
entry[:args] = server.args if server.stdio? && !server.args.empty?
|
|
40
|
+
entry[:env] = server.env unless server.env.empty?
|
|
41
|
+
entry[:headers] = server.headers if server.http? && !server.headers.empty?
|
|
42
|
+
memo[server.name] = entry
|
|
43
|
+
end
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def translate_for_codex(mcp_servers)
|
|
48
|
+
{
|
|
49
|
+
mcp_servers: mcp_servers.each_with_object({}) do |server, memo|
|
|
50
|
+
entry = server.stdio? ? {command: server.command, transport: server.transport} : {url: server.url, transport: server.transport}
|
|
51
|
+
entry[:args] = server.args if server.stdio? && !server.args.empty?
|
|
52
|
+
entry[:env] = server.env unless server.env.empty?
|
|
53
|
+
entry[:headers] = server.headers if server.http? && !server.headers.empty?
|
|
54
|
+
memo[server.name] = entry
|
|
55
|
+
end
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def translate_for_openai(mcp_servers)
|
|
60
|
+
mcp_servers.map do |server|
|
|
61
|
+
unless server.http?
|
|
62
|
+
raise McpTransportUnsupportedError.new(
|
|
63
|
+
"Provider 'openai' only supports remote MCP servers over HTTP/SSE (server: '#{server.name}')",
|
|
64
|
+
provider: :openai
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
unsupported_headers = server.headers.keys - ["Authorization"]
|
|
69
|
+
unless unsupported_headers.empty?
|
|
70
|
+
raise McpConfigurationError,
|
|
71
|
+
"OpenAI MCP translation only supports the Authorization header (server: '#{server.name}')"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
tool = {
|
|
75
|
+
type: "mcp",
|
|
76
|
+
server_label: server.name,
|
|
77
|
+
server_url: server.url,
|
|
78
|
+
require_approval: "never"
|
|
79
|
+
}
|
|
80
|
+
tool[:authorization] = server.headers["Authorization"] if server.headers.key?("Authorization")
|
|
81
|
+
tool
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -10,7 +10,8 @@ module AgentHarness
|
|
|
10
10
|
# McpServer.new(
|
|
11
11
|
# name: "filesystem",
|
|
12
12
|
# transport: "stdio",
|
|
13
|
-
# command:
|
|
13
|
+
# command: "npx",
|
|
14
|
+
# args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
|
|
14
15
|
# env: { "DEBUG" => "0" }
|
|
15
16
|
# )
|
|
16
17
|
#
|
|
@@ -23,21 +24,24 @@ module AgentHarness
|
|
|
23
24
|
class McpServer
|
|
24
25
|
VALID_TRANSPORTS = %w[stdio http sse].freeze
|
|
25
26
|
|
|
26
|
-
attr_reader :name, :transport, :command, :args, :env, :url
|
|
27
|
+
attr_reader :name, :transport, :command, :args, :env, :url, :headers
|
|
27
28
|
|
|
28
29
|
# @param name [String] unique name for this MCP server
|
|
29
30
|
# @param transport [String] one of "stdio", "http", "sse"
|
|
30
|
-
# @param command [Array<String>, nil]
|
|
31
|
+
# @param command [String, Array<String>, nil] executable to launch (stdio only).
|
|
32
|
+
# Arrays are accepted for backwards compatibility and normalized into
|
|
33
|
+
# +command+ plus +args+.
|
|
31
34
|
# @param args [Array<String>, nil] additional args for the command
|
|
32
35
|
# @param env [Hash<String,String>, nil] environment variables for the server process
|
|
33
36
|
# @param url [String, nil] URL for HTTP/SSE transport
|
|
34
|
-
|
|
37
|
+
# @param headers [Hash<String,String>, nil] HTTP headers for remote MCP servers
|
|
38
|
+
def initialize(name:, transport:, command: nil, args: nil, env: nil, url: nil, headers: nil)
|
|
35
39
|
@name = name
|
|
36
40
|
@transport = transport.to_s
|
|
37
|
-
@command = command
|
|
38
|
-
@args = args || []
|
|
41
|
+
@command, @args = normalize_command_and_args(command, args)
|
|
39
42
|
@env = env || {}
|
|
40
43
|
@url = url
|
|
44
|
+
@headers = headers || {}
|
|
41
45
|
|
|
42
46
|
validate!
|
|
43
47
|
end
|
|
@@ -63,7 +67,8 @@ module AgentHarness
|
|
|
63
67
|
command: hash[:command],
|
|
64
68
|
args: hash[:args],
|
|
65
69
|
env: hash[:env],
|
|
66
|
-
url: hash[:url]
|
|
70
|
+
url: hash[:url],
|
|
71
|
+
headers: hash[:headers]
|
|
67
72
|
)
|
|
68
73
|
end
|
|
69
74
|
|
|
@@ -75,6 +80,12 @@ module AgentHarness
|
|
|
75
80
|
%w[http sse].include?(@transport)
|
|
76
81
|
end
|
|
77
82
|
|
|
83
|
+
def command_argv
|
|
84
|
+
return [] unless stdio?
|
|
85
|
+
|
|
86
|
+
[@command, *@args]
|
|
87
|
+
end
|
|
88
|
+
|
|
78
89
|
# Check if the MCP server is reachable based on its transport type.
|
|
79
90
|
#
|
|
80
91
|
# For stdio servers, checks that a command is present.
|
|
@@ -101,6 +112,7 @@ module AgentHarness
|
|
|
101
112
|
h[:args] = @args unless @args.empty?
|
|
102
113
|
else
|
|
103
114
|
h[:url] = @url
|
|
115
|
+
h[:headers] = @headers unless @headers.empty?
|
|
104
116
|
end
|
|
105
117
|
h[:env] = @env unless @env.empty?
|
|
106
118
|
h
|
|
@@ -118,6 +130,7 @@ module AgentHarness
|
|
|
118
130
|
|
|
119
131
|
validate_args!
|
|
120
132
|
validate_env!
|
|
133
|
+
validate_headers!
|
|
121
134
|
validate_stdio! if stdio?
|
|
122
135
|
validate_http! if http?
|
|
123
136
|
validate_no_stdio_only_fields_on_http! if http?
|
|
@@ -137,15 +150,17 @@ module AgentHarness
|
|
|
137
150
|
"MCP server '#{@name}' env must be a Hash with String keys and values"
|
|
138
151
|
end
|
|
139
152
|
|
|
140
|
-
def
|
|
141
|
-
if @
|
|
142
|
-
raise McpConfigurationError,
|
|
143
|
-
"MCP server '#{@name}' with stdio transport requires a non-empty command array"
|
|
144
|
-
end
|
|
153
|
+
def validate_headers!
|
|
154
|
+
return if @headers.is_a?(Hash) && @headers.keys.all? { |k| k.is_a?(String) } && @headers.values.all? { |v| v.is_a?(String) }
|
|
145
155
|
|
|
146
|
-
|
|
156
|
+
raise McpConfigurationError,
|
|
157
|
+
"MCP server '#{@name}' headers must be a Hash with String keys and values"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_stdio!
|
|
161
|
+
if @command.nil? || !@command.is_a?(String) || @command.strip.empty?
|
|
147
162
|
raise McpConfigurationError,
|
|
148
|
-
"MCP server '#{@name}'
|
|
163
|
+
"MCP server '#{@name}' with stdio transport requires a non-empty command string"
|
|
149
164
|
end
|
|
150
165
|
|
|
151
166
|
return if @url.nil?
|
|
@@ -173,6 +188,17 @@ module AgentHarness
|
|
|
173
188
|
"MCP server '#{@name}' with #{@transport} transport should not have args (args are only valid for stdio)"
|
|
174
189
|
end
|
|
175
190
|
|
|
191
|
+
def normalize_command_and_args(command, args)
|
|
192
|
+
normalized_args = args || []
|
|
193
|
+
return [command, normalized_args] unless command.is_a?(Array)
|
|
194
|
+
|
|
195
|
+
unless normalized_args.is_a?(Array)
|
|
196
|
+
return [command.first, normalized_args]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
[command.first, command[1..] + normalized_args]
|
|
200
|
+
end
|
|
201
|
+
|
|
176
202
|
def http_ping_ok?(timeout: 5)
|
|
177
203
|
require "net/http"
|
|
178
204
|
uri = URI.parse(@url)
|
|
@@ -15,6 +15,7 @@ module AgentHarness
|
|
|
15
15
|
# response = provider.send_message(prompt: "Hello!")
|
|
16
16
|
class Anthropic < Base
|
|
17
17
|
include RateLimitResetParsing
|
|
18
|
+
include McpConfigFileSupport
|
|
18
19
|
|
|
19
20
|
# Model name pattern for Anthropic Claude models
|
|
20
21
|
MODEL_PATTERN = /^claude-[\d.-]+-(?:opus|sonnet|haiku)(?:-\d{8})?$/i
|
|
@@ -756,85 +757,8 @@ module AgentHarness
|
|
|
756
757
|
servers
|
|
757
758
|
end
|
|
758
759
|
|
|
759
|
-
def
|
|
760
|
-
|
|
761
|
-
require "tmpdir"
|
|
762
|
-
require "securerandom"
|
|
763
|
-
|
|
764
|
-
config = build_claude_mcp_config(mcp_servers)
|
|
765
|
-
config_json = JSON.generate(config)
|
|
766
|
-
|
|
767
|
-
if @executor.is_a?(DockerCommandExecutor)
|
|
768
|
-
# When running inside a Docker container, write the config file
|
|
769
|
-
# inside the container so the CLI process can read it.
|
|
770
|
-
# Track the path so cleanup_mcp_tempfiles! can remove it after execution.
|
|
771
|
-
container_path = "/tmp/agent_harness_mcp_#{SecureRandom.hex(8)}.json"
|
|
772
|
-
result = @executor.execute(
|
|
773
|
-
["sh", "-c", "cat > #{container_path}"],
|
|
774
|
-
stdin_data: config_json,
|
|
775
|
-
timeout: 5
|
|
776
|
-
)
|
|
777
|
-
unless result.success?
|
|
778
|
-
raise McpConfigurationError,
|
|
779
|
-
"Failed to write MCP config inside container: #{result.stderr}"
|
|
780
|
-
end
|
|
781
|
-
|
|
782
|
-
@mcp_docker_config_paths ||= []
|
|
783
|
-
@mcp_docker_config_paths << container_path
|
|
784
|
-
|
|
785
|
-
container_path
|
|
786
|
-
else
|
|
787
|
-
dir = working_dir || Dir.tmpdir
|
|
788
|
-
file = Tempfile.new(["agent_harness_mcp_", ".json"], dir)
|
|
789
|
-
file.write(config_json)
|
|
790
|
-
file.close
|
|
791
|
-
|
|
792
|
-
# Hold a reference so the Tempfile is not garbage-collected (and
|
|
793
|
-
# therefore deleted) before the CLI process reads it.
|
|
794
|
-
# Cleaned up by cleanup_mcp_tempfiles! after execution.
|
|
795
|
-
@mcp_config_tempfiles ||= []
|
|
796
|
-
@mcp_config_tempfiles << file
|
|
797
|
-
|
|
798
|
-
file.path
|
|
799
|
-
end
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
def build_claude_mcp_config(mcp_servers)
|
|
803
|
-
servers = {}
|
|
804
|
-
mcp_servers.each do |server|
|
|
805
|
-
h = if server.stdio?
|
|
806
|
-
entry = {command: server.command.first}
|
|
807
|
-
remaining_args = server.command[1..] + server.args
|
|
808
|
-
entry[:args] = remaining_args unless remaining_args.empty?
|
|
809
|
-
entry
|
|
810
|
-
else
|
|
811
|
-
{url: server.url}
|
|
812
|
-
end
|
|
813
|
-
h[:env] = server.env unless server.env.empty?
|
|
814
|
-
servers[server.name] = h
|
|
815
|
-
end
|
|
816
|
-
{mcpServers: servers}
|
|
817
|
-
end
|
|
818
|
-
|
|
819
|
-
def cleanup_mcp_tempfiles!
|
|
820
|
-
if @mcp_config_tempfiles
|
|
821
|
-
@mcp_config_tempfiles.each do |file|
|
|
822
|
-
file.close unless file.closed?
|
|
823
|
-
file.unlink
|
|
824
|
-
rescue
|
|
825
|
-
nil
|
|
826
|
-
end
|
|
827
|
-
@mcp_config_tempfiles = nil
|
|
828
|
-
end
|
|
829
|
-
|
|
830
|
-
if @mcp_docker_config_paths
|
|
831
|
-
@mcp_docker_config_paths.each do |path|
|
|
832
|
-
@executor.execute(["rm", "-f", path], timeout: 5)
|
|
833
|
-
rescue
|
|
834
|
-
nil
|
|
835
|
-
end
|
|
836
|
-
@mcp_docker_config_paths = nil
|
|
837
|
-
end
|
|
760
|
+
def mcp_provider_key
|
|
761
|
+
:anthropic
|
|
838
762
|
end
|
|
839
763
|
|
|
840
764
|
def build_tool_control_flags(tools_option, skip_permission_mode: false)
|
|
@@ -25,6 +25,7 @@ module AgentHarness
|
|
|
25
25
|
# end
|
|
26
26
|
class Base
|
|
27
27
|
include Adapter
|
|
28
|
+
include Extensions::DeepDupable
|
|
28
29
|
|
|
29
30
|
DEFAULT_SMOKE_TEST_CONTRACT = {
|
|
30
31
|
prompt: "Reply with exactly OK.",
|
|
@@ -76,9 +77,11 @@ module AgentHarness
|
|
|
76
77
|
# @param config [ProviderConfig, nil] provider configuration
|
|
77
78
|
# @param executor [CommandExecutor, nil] command executor
|
|
78
79
|
# @param logger [Logger, nil] logger instance
|
|
79
|
-
|
|
80
|
+
# @param configuration [Configuration, nil] parent configuration for extension/sub-agent resolution
|
|
81
|
+
def initialize(config: nil, executor: nil, logger: nil, configuration: nil)
|
|
80
82
|
@config = config || ProviderConfig.new(self.class.provider_name)
|
|
81
|
-
@
|
|
83
|
+
@configuration = configuration || AgentHarness.configuration
|
|
84
|
+
@executor = executor || @configuration.command_executor
|
|
82
85
|
@logger = logger || AgentHarness.logger
|
|
83
86
|
end
|
|
84
87
|
|
|
@@ -126,6 +129,15 @@ module AgentHarness
|
|
|
126
129
|
|
|
127
130
|
# Coerce provider_runtime from Hash if needed
|
|
128
131
|
options = normalize_provider_runtime(options)
|
|
132
|
+
|
|
133
|
+
# Capture execution options (callbacks, observer) before extensions
|
|
134
|
+
# processing deep-dups the options hash, which would replace identity-
|
|
135
|
+
# sensitive references (observers, procs) with clones.
|
|
136
|
+
exec_opts = command_execution_options(options)
|
|
137
|
+
|
|
138
|
+
extension_context = apply_extensions_to_prompt(prompt, options)
|
|
139
|
+
prompt = extension_context.prompt
|
|
140
|
+
options = extension_context.options
|
|
129
141
|
options = normalize_sub_agent(options)
|
|
130
142
|
prompt = apply_sub_agent_to_prompt(prompt, options[:translated_sub_agent])
|
|
131
143
|
|
|
@@ -147,7 +159,7 @@ module AgentHarness
|
|
|
147
159
|
timeout: timeout,
|
|
148
160
|
env: build_env(options),
|
|
149
161
|
preparation: preparation,
|
|
150
|
-
**
|
|
162
|
+
**exec_opts
|
|
151
163
|
)
|
|
152
164
|
duration = Time.now - start_time
|
|
153
165
|
|
|
@@ -171,13 +183,15 @@ module AgentHarness
|
|
|
171
183
|
)
|
|
172
184
|
end
|
|
173
185
|
|
|
186
|
+
response = apply_extensions_after_response(extension_context, response)
|
|
187
|
+
|
|
174
188
|
# Track tokens
|
|
175
189
|
track_tokens(response) if response.tokens
|
|
176
190
|
|
|
177
191
|
log_debug("send_message_complete", duration: duration, tokens: response.tokens)
|
|
178
192
|
|
|
179
193
|
response
|
|
180
|
-
rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
194
|
+
rescue ExtensionCompatibilityError, McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
181
195
|
raise
|
|
182
196
|
rescue => e
|
|
183
197
|
handle_error(e, prompt: prompt, options: options)
|
|
@@ -220,7 +234,12 @@ module AgentHarness
|
|
|
220
234
|
|
|
221
235
|
transport = resolve_chat_transport(options)
|
|
222
236
|
messages = format_messages_for_transport(conversation, transport)
|
|
237
|
+
extension_context = apply_extensions_to_chat(messages, tools, options)
|
|
238
|
+
messages = extension_context.messages
|
|
239
|
+
tools = extension_context.tools
|
|
240
|
+
options = extension_context.options
|
|
223
241
|
messages = apply_sub_agent_to_messages(messages, options[:translated_sub_agent])
|
|
242
|
+
validate_chat_mcp_servers!(options[:mcp_servers])
|
|
224
243
|
transport_opts = chat_transport_options(runtime, options)
|
|
225
244
|
transport_opts[:on_chat_chunk] = on_chat_chunk if on_chat_chunk
|
|
226
245
|
transport_opts[:observer] = observer if observer
|
|
@@ -233,11 +252,13 @@ module AgentHarness
|
|
|
233
252
|
&on_chunk
|
|
234
253
|
)
|
|
235
254
|
|
|
255
|
+
response = apply_extensions_after_response(extension_context, response)
|
|
256
|
+
|
|
236
257
|
track_tokens(response) if response.tokens
|
|
237
258
|
log_debug("send_chat_message_complete", duration: response.duration, tokens: response.tokens)
|
|
238
259
|
|
|
239
260
|
response
|
|
240
|
-
rescue ProviderError, AuthenticationError, RateLimitError, TimeoutError
|
|
261
|
+
rescue ExtensionCompatibilityError, ProviderError, AuthenticationError, RateLimitError, TimeoutError
|
|
241
262
|
raise
|
|
242
263
|
rescue => e
|
|
243
264
|
last_msg = conversation&.last || messages&.last
|
|
@@ -419,7 +440,13 @@ module AgentHarness
|
|
|
419
440
|
end
|
|
420
441
|
|
|
421
442
|
def normalize_mcp_servers(options)
|
|
422
|
-
|
|
443
|
+
if options.key?(:mcp_servers)
|
|
444
|
+
servers = options[:mcp_servers]
|
|
445
|
+
else
|
|
446
|
+
# Configuration stores mcp_servers as a Hash keyed by name; extract values.
|
|
447
|
+
config_servers = @configuration.mcp_servers
|
|
448
|
+
servers = config_servers.is_a?(Hash) ? config_servers.values : config_servers
|
|
449
|
+
end
|
|
423
450
|
return options if servers.nil?
|
|
424
451
|
|
|
425
452
|
unless servers.is_a?(Array)
|
|
@@ -454,12 +481,12 @@ module AgentHarness
|
|
|
454
481
|
sub_agent = options[:sub_agent]
|
|
455
482
|
return options unless sub_agent
|
|
456
483
|
|
|
457
|
-
resolved =
|
|
484
|
+
resolved = @configuration.resolve_sub_agent(sub_agent)
|
|
458
485
|
translated = SubAgentTranslator.for_provider(
|
|
459
486
|
self.class.provider_name,
|
|
460
487
|
resolved,
|
|
461
|
-
tool_registry:
|
|
462
|
-
mcp_servers:
|
|
488
|
+
tool_registry: @configuration.tool_registry,
|
|
489
|
+
mcp_servers: @configuration.mcp_servers
|
|
463
490
|
)
|
|
464
491
|
|
|
465
492
|
options.merge(sub_agent: resolved, translated_sub_agent: translated)
|
|
@@ -477,6 +504,182 @@ module AgentHarness
|
|
|
477
504
|
[{role: "system", content: translated_sub_agent[:runtime_instructions]}] + messages
|
|
478
505
|
end
|
|
479
506
|
|
|
507
|
+
def resolve_extensions(options)
|
|
508
|
+
Array(options[:extensions]).filter_map do |reference|
|
|
509
|
+
@configuration.resolve_extension(reference)
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def apply_extensions_to_prompt(prompt, options)
|
|
514
|
+
extensions = resolve_extensions(options)
|
|
515
|
+
strict = options.fetch(:extensions_strict, true)
|
|
516
|
+
Extensions::Composition.compose(extensions) if extensions.size > 1
|
|
517
|
+
context = Extensions::MessageContext.new(
|
|
518
|
+
provider: self,
|
|
519
|
+
extensions: extensions,
|
|
520
|
+
mode: :message,
|
|
521
|
+
prompt: prompt,
|
|
522
|
+
options: deep_dup(options),
|
|
523
|
+
metadata: {}
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
validate_extensions!(extensions, strict: strict)
|
|
527
|
+
reject_tool_extensions_in_message_mode!(extensions)
|
|
528
|
+
reject_mcp_extensions_in_message_mode!(extensions)
|
|
529
|
+
apply_extension_system_prompt!(context)
|
|
530
|
+
extensions.each { |extension| extension.on_message_before(context) }
|
|
531
|
+
context
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def apply_extensions_to_chat(messages, tools, options)
|
|
535
|
+
extensions = resolve_extensions(options)
|
|
536
|
+
strict = options.fetch(:extensions_strict, true)
|
|
537
|
+
Extensions::Composition.compose(extensions) if extensions.size > 1
|
|
538
|
+
context = Extensions::MessageContext.new(
|
|
539
|
+
provider: self,
|
|
540
|
+
extensions: extensions,
|
|
541
|
+
mode: :chat,
|
|
542
|
+
messages: deep_dup(messages),
|
|
543
|
+
tools: merge_extension_tools(tools, extensions),
|
|
544
|
+
options: deep_dup(options),
|
|
545
|
+
metadata: {}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
validate_extensions!(extensions, strict: strict)
|
|
549
|
+
merge_extension_mcp_servers!(context)
|
|
550
|
+
apply_extension_system_messages!(context)
|
|
551
|
+
extensions.each { |extension| extension.on_message_before(context) }
|
|
552
|
+
extensions.each { |extension| extension.on_tools_available(context) } if context.tools&.any?
|
|
553
|
+
context
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def apply_extensions_after_response(context, response)
|
|
557
|
+
return response unless context
|
|
558
|
+
|
|
559
|
+
context.response = response
|
|
560
|
+
context.extensions.each { |extension| extension.on_message_after(context) }
|
|
561
|
+
context.response
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def validate_extensions!(extensions, strict: true)
|
|
565
|
+
extensions.each do |extension|
|
|
566
|
+
report = Extensions::Compatibility.check!(provider: self, extension: extension, strict: strict)
|
|
567
|
+
next if report.fully_supported?
|
|
568
|
+
|
|
569
|
+
@logger&.warn(
|
|
570
|
+
"[AgentHarness::#{self.class.provider_name}] Extension '#{extension.name}' has " \
|
|
571
|
+
"unsupported features that will be unavailable: #{report.unsupported_features.inspect}"
|
|
572
|
+
)
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def validate_chat_mcp_servers!(mcp_servers)
|
|
577
|
+
return if mcp_servers.nil? || mcp_servers.empty?
|
|
578
|
+
|
|
579
|
+
# Chat transports do not support request-scoped MCP servers.
|
|
580
|
+
# Raise early so extensions with MCP requirements are not silently ignored.
|
|
581
|
+
raise McpUnsupportedError.new(
|
|
582
|
+
"Chat mode does not support request-scoped MCP servers. " \
|
|
583
|
+
"Extensions or options requiring MCP servers cannot be used with send_chat_message.",
|
|
584
|
+
provider: self.class.provider_name
|
|
585
|
+
)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def reject_tool_extensions_in_message_mode!(extensions)
|
|
589
|
+
tool_extensions = extensions.select { |ext| ext.tools.any? }
|
|
590
|
+
return if tool_extensions.empty?
|
|
591
|
+
|
|
592
|
+
names = tool_extensions.map(&:name).join(", ")
|
|
593
|
+
raise ExtensionCompatibilityError.new(
|
|
594
|
+
"Extensions with tools are not supported in message mode (CLI execution " \
|
|
595
|
+
"cannot accept dynamic tool definitions): #{names}",
|
|
596
|
+
provider: self.class.provider_name
|
|
597
|
+
)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def reject_mcp_extensions_in_message_mode!(extensions)
|
|
601
|
+
mcp_extensions = extensions.select { |ext| ext.mcp_servers.any? }
|
|
602
|
+
return if mcp_extensions.empty?
|
|
603
|
+
|
|
604
|
+
names = mcp_extensions.map(&:name).join(", ")
|
|
605
|
+
raise ExtensionCompatibilityError.new(
|
|
606
|
+
"Extensions with MCP servers are not supported in message mode: #{names}",
|
|
607
|
+
provider: self.class.provider_name
|
|
608
|
+
)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def merge_extension_mcp_servers!(context)
|
|
612
|
+
extension_servers = context.extensions.flat_map(&:mcp_servers)
|
|
613
|
+
return if extension_servers.empty?
|
|
614
|
+
|
|
615
|
+
merged = Array(context.options[:mcp_servers]) + extension_servers
|
|
616
|
+
context.options = context.options.merge(mcp_servers: merged)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def merge_extension_tools(tools, extensions)
|
|
620
|
+
extension_tools = extensions.flat_map(&:tools)
|
|
621
|
+
return tools unless extension_tools.any?
|
|
622
|
+
|
|
623
|
+
normalized_extension_tools = extension_tools.map { |t| normalize_extension_tool_for_provider(t) }
|
|
624
|
+
merged = Array(tools) + normalized_extension_tools
|
|
625
|
+
names = merged.map { |t| extract_tool_name(t) }.compact
|
|
626
|
+
duplicates = names.group_by { |n| n }.select { |_, v| v.size > 1 }.keys
|
|
627
|
+
unless duplicates.empty?
|
|
628
|
+
raise ConfigurationError,
|
|
629
|
+
"Tool name conflict between user-provided and extension tools: #{duplicates.join(", ")}"
|
|
630
|
+
end
|
|
631
|
+
merged
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def extract_tool_name(tool)
|
|
635
|
+
tool[:name] || tool["name"] ||
|
|
636
|
+
tool.dig(:function, :name) || tool.dig(:function, "name") ||
|
|
637
|
+
tool.dig("function", "name") || tool.dig("function", :name)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def normalize_extension_tool_for_provider(tool)
|
|
641
|
+
case chat_transport_type
|
|
642
|
+
when :openai_compatible
|
|
643
|
+
# OpenAI-style: {type: "function", function: {name: ..., description: ...}}
|
|
644
|
+
return tool if tool[:type] == "function" || tool["type"] == "function"
|
|
645
|
+
|
|
646
|
+
func = {name: tool[:name] || tool["name"]}
|
|
647
|
+
description = tool[:description] || tool["description"]
|
|
648
|
+
func[:description] = description if description
|
|
649
|
+
parameters = tool[:parameters] || tool["parameters"]
|
|
650
|
+
func[:parameters] = parameters if parameters
|
|
651
|
+
{type: "function", function: func}
|
|
652
|
+
else
|
|
653
|
+
# Anthropic-style: {name: ..., description: ..., input_schema: ...}
|
|
654
|
+
# Convert `parameters` key to `input_schema` for Anthropic/TextTransport compatibility.
|
|
655
|
+
normalized = tool.dup
|
|
656
|
+
params = normalized.delete(:parameters) || normalized.delete("parameters")
|
|
657
|
+
if params && !normalized.key?(:input_schema) && !normalized.key?("input_schema")
|
|
658
|
+
normalized[:input_schema] = params
|
|
659
|
+
end
|
|
660
|
+
normalized
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def apply_extension_system_prompt!(context)
|
|
665
|
+
additions = context.extensions.flat_map(&:system_prompt_additions).reject do |addition|
|
|
666
|
+
addition.nil? || addition.empty?
|
|
667
|
+
end
|
|
668
|
+
return if additions.empty?
|
|
669
|
+
|
|
670
|
+
context.prompt = [additions.join("\n\n"), context.prompt].join("\n\n")
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def apply_extension_system_messages!(context)
|
|
674
|
+
additions = context.extensions.flat_map(&:system_prompt_additions).reject do |addition|
|
|
675
|
+
addition.nil? || addition.empty?
|
|
676
|
+
end
|
|
677
|
+
return if additions.empty?
|
|
678
|
+
|
|
679
|
+
system_messages = additions.map { |addition| {role: "system", content: addition} }
|
|
680
|
+
context.messages = system_messages + context.messages
|
|
681
|
+
end
|
|
682
|
+
|
|
480
683
|
def command_execution_options(options)
|
|
481
684
|
execution_options = {
|
|
482
685
|
idle_timeout: options[:idle_timeout],
|
|
@@ -9,6 +9,7 @@ module AgentHarness
|
|
|
9
9
|
# Provides integration with the OpenAI Codex CLI tool.
|
|
10
10
|
class Codex < Base
|
|
11
11
|
include RateLimitResetParsing
|
|
12
|
+
include McpConfigFileSupport
|
|
12
13
|
|
|
13
14
|
SUPPORTED_CLI_VERSION = "0.116.0"
|
|
14
15
|
SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 0.117.0").freeze
|
|
@@ -186,7 +187,7 @@ module AgentHarness
|
|
|
186
187
|
vision: false,
|
|
187
188
|
tool_use: true,
|
|
188
189
|
json_mode: false,
|
|
189
|
-
mcp:
|
|
190
|
+
mcp: true,
|
|
190
191
|
dangerous_mode: true
|
|
191
192
|
}
|
|
192
193
|
end
|
|
@@ -199,6 +200,27 @@ module AgentHarness
|
|
|
199
200
|
|
|
200
201
|
def cli_env_overrides = {"PAID_CODEX_SUBSCRIPTION_AUTH" => "1"}
|
|
201
202
|
|
|
203
|
+
def send_message(prompt:, **options)
|
|
204
|
+
super
|
|
205
|
+
ensure
|
|
206
|
+
cleanup_mcp_tempfiles!
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def supports_mcp?
|
|
210
|
+
true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def supported_mcp_transports
|
|
214
|
+
%w[stdio http sse]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def build_mcp_flags(mcp_servers, working_dir: nil)
|
|
218
|
+
return [] if mcp_servers.empty?
|
|
219
|
+
|
|
220
|
+
config_path = write_mcp_config_file(mcp_servers, working_dir: working_dir)
|
|
221
|
+
["--mcp-config", config_path]
|
|
222
|
+
end
|
|
223
|
+
|
|
202
224
|
def test_command_overrides
|
|
203
225
|
["--skip-git-repo-check", "--output-last-message"]
|
|
204
226
|
end
|
|
@@ -449,6 +471,11 @@ module AgentHarness
|
|
|
449
471
|
cmd += sandbox_bypass_flags
|
|
450
472
|
end
|
|
451
473
|
|
|
474
|
+
# Add MCP server flags (validated/normalized by Base#send_message)
|
|
475
|
+
if options[:mcp_servers]&.any?
|
|
476
|
+
cmd += build_mcp_flags(options[:mcp_servers])
|
|
477
|
+
end
|
|
478
|
+
|
|
452
479
|
if options[:session]
|
|
453
480
|
cmd += session_flags(options[:session])
|
|
454
481
|
end
|
|
@@ -1259,6 +1286,10 @@ module AgentHarness
|
|
|
1259
1286
|
config_dir = ENV["CODEX_CONFIG_DIR"] || File.expand_path("~/.codex")
|
|
1260
1287
|
File.join(config_dir, "config.json")
|
|
1261
1288
|
end
|
|
1289
|
+
|
|
1290
|
+
def mcp_provider_key
|
|
1291
|
+
:codex
|
|
1292
|
+
end
|
|
1262
1293
|
end
|
|
1263
1294
|
end
|
|
1264
1295
|
end
|