agent-harness 0.11.0 → 0.11.2
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 +153 -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 +38 -1
- 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/sub_agent_config.rb +118 -0
- data/lib/agent_harness/sub_agent_file_loader.rb +55 -0
- data/lib/agent_harness/sub_agent_translator.rb +243 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +23 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50b5bc213cf4a1b6f9441a06a82c38210c7e6907eb085e6cadf71e305e4b7897
|
|
4
|
+
data.tar.gz: 9785ea0b1b35f5aa52528741ce070287f2d433235ffeaf3d6df0ab7337f486b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2343d812d85375faad3a55e0462eb228003ff5eddc1920fd1edb75aedf381e5b86e3062c191ee64d24c39f245c1f27ded7d0e74247526727901b6b5391c2518a
|
|
7
|
+
data.tar.gz: cfe73ff0ffce00f5e0be1726b3dc28d0cdb73aab6546988c19b79846c1d16014476302f2d258d13bb703124edbf86066a96cd43044047ebacc81ad0cf5c853e5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.11.2](https://github.com/viamin/agent-harness/compare/agent-harness/v0.11.1...agent-harness/v0.11.2) (2026-04-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* 162: Support provider-agnostic MCP configuration ([#165](https://github.com/viamin/agent-harness/issues/165)) ([27f4814](https://github.com/viamin/agent-harness/commit/27f48146e99d8fdba0346235a3e5f19138266652))
|
|
9
|
+
|
|
10
|
+
## [0.11.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.11.0...agent-harness/v0.11.1) (2026-04-26)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* 163: Support provider-agnostic sub-agent definitions ([#166](https://github.com/viamin/agent-harness/issues/166)) ([1a00c35](https://github.com/viamin/agent-harness/commit/1a00c35f61c624b23f8d37b0d1b41e007d007ad3))
|
|
16
|
+
|
|
3
17
|
## [0.11.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.10.0...agent-harness/v0.11.0) (2026-04-25)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -23,6 +23,7 @@ module AgentHarness
|
|
|
23
23
|
attr_writer :command_executor
|
|
24
24
|
|
|
25
25
|
attr_reader :providers, :orchestration_config, :callbacks, :custom_provider_classes
|
|
26
|
+
attr_reader :sub_agents, :tool_registry, :mcp_servers
|
|
26
27
|
|
|
27
28
|
def initialize
|
|
28
29
|
@logger = nil # Will use null logger if not set
|
|
@@ -36,6 +37,9 @@ module AgentHarness
|
|
|
36
37
|
@orchestration_config = OrchestrationConfig.new
|
|
37
38
|
@callbacks = CallbackRegistry.new
|
|
38
39
|
@custom_provider_classes = {}
|
|
40
|
+
@sub_agents = {}
|
|
41
|
+
@tool_registry = ToolRegistry.new
|
|
42
|
+
@mcp_servers = {}
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
# Get or lazily initialize the command executor
|
|
@@ -74,6 +78,94 @@ module AgentHarness
|
|
|
74
78
|
@custom_provider_classes[name.to_sym] = klass
|
|
75
79
|
end
|
|
76
80
|
|
|
81
|
+
# Set MCP servers from an array of server definitions.
|
|
82
|
+
#
|
|
83
|
+
# @param servers [Array<Hash, McpServer>] server definitions
|
|
84
|
+
def mcp_servers=(servers)
|
|
85
|
+
normalized = McpConfigTranslator.normalize_servers(servers)
|
|
86
|
+
@mcp_servers = normalized.each_with_object({}) { |s, h| h[s.name.to_sym] = s }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Load MCP server definitions from a configuration file.
|
|
90
|
+
#
|
|
91
|
+
# @param path [String] file path
|
|
92
|
+
def load_mcp_servers_file(path)
|
|
93
|
+
loaded = McpConfigLoader.load_file(path)
|
|
94
|
+
@mcp_servers = loaded.each_with_object({}) { |s, h| h[s.name.to_sym] = s }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Configure a provider-agnostic sub-agent definition.
|
|
98
|
+
#
|
|
99
|
+
# @param name [Symbol, String] sub-agent name
|
|
100
|
+
# @param attributes [Hash] sub-agent attributes
|
|
101
|
+
# @yield [Hash] mutable attributes hash before validation
|
|
102
|
+
# @return [SubAgentConfig] registered sub-agent config
|
|
103
|
+
def sub_agent(name, attributes = {})
|
|
104
|
+
attributes = attributes.dup
|
|
105
|
+
yield(attributes) if block_given?
|
|
106
|
+
attributes[:name] = name
|
|
107
|
+
|
|
108
|
+
config = SubAgentConfig.from_hash(attributes)
|
|
109
|
+
@sub_agents[config.name] = config
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Load sub-agent definitions from a YAML or Markdown file.
|
|
113
|
+
#
|
|
114
|
+
# @param path [String] file path
|
|
115
|
+
# @return [Array<SubAgentConfig>] loaded sub-agents
|
|
116
|
+
def load_sub_agents(path)
|
|
117
|
+
SubAgentFileLoader.load(path).each do |sub_agent|
|
|
118
|
+
@sub_agents[sub_agent.name] = sub_agent
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Register a generic tool definition that sub-agents can reference.
|
|
123
|
+
#
|
|
124
|
+
# @param name [Symbol, String] tool name
|
|
125
|
+
# @param description [String, nil] tool description
|
|
126
|
+
# @param provider_mappings [Hash] provider-specific mappings
|
|
127
|
+
# @return [ToolDefinition]
|
|
128
|
+
def register_tool(name, description: nil, **provider_mappings)
|
|
129
|
+
@tool_registry.register(name, description: description, **provider_mappings)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Register a named MCP server for later reference by sub-agents.
|
|
133
|
+
#
|
|
134
|
+
# @param name [Symbol, String] server name
|
|
135
|
+
# @param definition [Hash, McpServer, nil] server definition
|
|
136
|
+
# @param attributes [Hash] server attributes
|
|
137
|
+
# @return [McpServer]
|
|
138
|
+
def register_mcp_server(name, definition = nil, **attributes)
|
|
139
|
+
server = if definition.is_a?(McpServer)
|
|
140
|
+
definition
|
|
141
|
+
else
|
|
142
|
+
payload = (definition || attributes).dup
|
|
143
|
+
payload[:name] ||= name.to_s
|
|
144
|
+
McpServer.from_hash(payload)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
@mcp_servers[name.to_sym] = server
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Resolve a named or inline sub-agent definition.
|
|
151
|
+
#
|
|
152
|
+
# @param reference [Symbol, String, Hash, SubAgentConfig, nil] sub-agent reference
|
|
153
|
+
# @return [SubAgentConfig, nil] resolved sub-agent config
|
|
154
|
+
def resolve_sub_agent(reference)
|
|
155
|
+
case reference
|
|
156
|
+
when nil
|
|
157
|
+
nil
|
|
158
|
+
when SubAgentConfig
|
|
159
|
+
reference
|
|
160
|
+
when Hash
|
|
161
|
+
SubAgentConfig.from_hash(reference)
|
|
162
|
+
else
|
|
163
|
+
@sub_agents.fetch(reference.to_sym) do
|
|
164
|
+
raise ConfigurationError, "Unknown sub-agent: #{reference}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
77
169
|
# Register callback for token usage events
|
|
78
170
|
#
|
|
79
171
|
# @yield [TokenEvent] called when tokens are used
|
|
@@ -263,6 +355,67 @@ module AgentHarness
|
|
|
263
355
|
end
|
|
264
356
|
end
|
|
265
357
|
|
|
358
|
+
# Provider-agnostic tool definition used during sub-agent translation.
|
|
359
|
+
class ToolDefinition
|
|
360
|
+
attr_reader :name, :description, :provider_mappings
|
|
361
|
+
|
|
362
|
+
def initialize(name:, description: nil, provider_mappings: {})
|
|
363
|
+
@name = name.to_sym
|
|
364
|
+
@description = description
|
|
365
|
+
@provider_mappings = deep_dup(provider_mappings).each_with_object({}) do |(provider, value), mappings|
|
|
366
|
+
mappings[provider.to_sym] = value
|
|
367
|
+
end.freeze
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def mapping_for(provider)
|
|
371
|
+
deep_dup(@provider_mappings[provider.to_sym])
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def to_h
|
|
375
|
+
{name: @name, description: @description, provider_mappings: deep_dup(@provider_mappings)}
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
private
|
|
379
|
+
|
|
380
|
+
def deep_dup(value)
|
|
381
|
+
case value
|
|
382
|
+
when Array
|
|
383
|
+
value.map { |entry| deep_dup(entry) }
|
|
384
|
+
when Hash
|
|
385
|
+
value.each_with_object({}) { |(key, entry), copy| copy[key] = deep_dup(entry) }
|
|
386
|
+
else
|
|
387
|
+
value.dup
|
|
388
|
+
end
|
|
389
|
+
rescue TypeError
|
|
390
|
+
value
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Registry for canonical tool definitions referenced by sub-agents.
|
|
395
|
+
class ToolRegistry
|
|
396
|
+
def initialize
|
|
397
|
+
@tools = {}
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def register(name, description: nil, **provider_mappings)
|
|
401
|
+
@tools[name.to_sym] = ToolDefinition.new(
|
|
402
|
+
name: name,
|
|
403
|
+
description: description,
|
|
404
|
+
provider_mappings: provider_mappings
|
|
405
|
+
)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def fetch(name)
|
|
409
|
+
@tools.fetch(name.to_sym) do
|
|
410
|
+
raise ConfigurationError, "Unknown tool: #{name}"
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def registered?(name)
|
|
415
|
+
@tools.key?(name.to_sym)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
266
419
|
# Registry for event callbacks
|
|
267
420
|
class CallbackRegistry
|
|
268
421
|
def initialize
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module AgentHarness
|
|
7
|
+
class McpConfigLoader
|
|
8
|
+
ENV_VAR_PATTERN = /\$\{([A-Z0-9_]+)\}/
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def load_file(path)
|
|
12
|
+
parsed = parse_file(path)
|
|
13
|
+
servers = parsed.is_a?(Hash) ? (parsed["servers"] || parsed[:servers] || parsed) : parsed
|
|
14
|
+
|
|
15
|
+
unless servers.is_a?(Array)
|
|
16
|
+
raise McpConfigurationError,
|
|
17
|
+
"MCP config file must contain a top-level servers array"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
servers.map do |server|
|
|
21
|
+
McpServer.from_hash(interpolate_env(server))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def parse_file(path)
|
|
28
|
+
ext = File.extname(path).downcase
|
|
29
|
+
content = File.read(path)
|
|
30
|
+
|
|
31
|
+
case ext
|
|
32
|
+
when ".json"
|
|
33
|
+
JSON.parse(content)
|
|
34
|
+
when ".yml", ".yaml"
|
|
35
|
+
YAML.safe_load(content, aliases: false) || {}
|
|
36
|
+
else
|
|
37
|
+
raise McpConfigurationError,
|
|
38
|
+
"Unsupported MCP config file format '#{ext}'. Use .json, .yml, or .yaml"
|
|
39
|
+
end
|
|
40
|
+
rescue Errno::ENOENT => e
|
|
41
|
+
raise McpConfigurationError, "MCP config file not found: #{e.message}"
|
|
42
|
+
rescue JSON::ParserError, Psych::SyntaxError => e
|
|
43
|
+
raise McpConfigurationError, "Failed to parse MCP config file: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def interpolate_env(value)
|
|
47
|
+
case value
|
|
48
|
+
when Array
|
|
49
|
+
value.map { |entry| interpolate_env(entry) }
|
|
50
|
+
when Hash
|
|
51
|
+
value.each_with_object({}) do |(key, entry), memo|
|
|
52
|
+
memo[key] = interpolate_env(entry)
|
|
53
|
+
end
|
|
54
|
+
when String
|
|
55
|
+
value.gsub(ENV_VAR_PATTERN) { ENV.fetch(Regexp.last_match(1), "") }
|
|
56
|
+
else
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -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)
|
|
@@ -126,6 +126,8 @@ module AgentHarness
|
|
|
126
126
|
|
|
127
127
|
# Coerce provider_runtime from Hash if needed
|
|
128
128
|
options = normalize_provider_runtime(options)
|
|
129
|
+
options = normalize_sub_agent(options)
|
|
130
|
+
prompt = apply_sub_agent_to_prompt(prompt, options[:translated_sub_agent])
|
|
129
131
|
|
|
130
132
|
# Normalize and validate MCP servers
|
|
131
133
|
options = normalize_mcp_servers(options)
|
|
@@ -210,6 +212,7 @@ module AgentHarness
|
|
|
210
212
|
end
|
|
211
213
|
|
|
212
214
|
options = normalize_provider_runtime(options)
|
|
215
|
+
options = normalize_sub_agent(options)
|
|
213
216
|
runtime = options[:provider_runtime]
|
|
214
217
|
conversation ||= messages
|
|
215
218
|
raise ArgumentError, "conversation or messages is required" unless conversation
|
|
@@ -217,6 +220,7 @@ module AgentHarness
|
|
|
217
220
|
|
|
218
221
|
transport = resolve_chat_transport(options)
|
|
219
222
|
messages = format_messages_for_transport(conversation, transport)
|
|
223
|
+
messages = apply_sub_agent_to_messages(messages, options[:translated_sub_agent])
|
|
220
224
|
transport_opts = chat_transport_options(runtime, options)
|
|
221
225
|
transport_opts[:on_chat_chunk] = on_chat_chunk if on_chat_chunk
|
|
222
226
|
transport_opts[:observer] = observer if observer
|
|
@@ -415,7 +419,13 @@ module AgentHarness
|
|
|
415
419
|
end
|
|
416
420
|
|
|
417
421
|
def normalize_mcp_servers(options)
|
|
418
|
-
|
|
422
|
+
if options.key?(:mcp_servers)
|
|
423
|
+
servers = options[:mcp_servers]
|
|
424
|
+
else
|
|
425
|
+
# Configuration stores mcp_servers as a Hash keyed by name; extract values.
|
|
426
|
+
config_servers = AgentHarness.configuration.mcp_servers
|
|
427
|
+
servers = config_servers.is_a?(Hash) ? config_servers.values : config_servers
|
|
428
|
+
end
|
|
419
429
|
return options if servers.nil?
|
|
420
430
|
|
|
421
431
|
unless servers.is_a?(Array)
|
|
@@ -446,6 +456,33 @@ module AgentHarness
|
|
|
446
456
|
options.merge(mcp_servers: normalized)
|
|
447
457
|
end
|
|
448
458
|
|
|
459
|
+
def normalize_sub_agent(options)
|
|
460
|
+
sub_agent = options[:sub_agent]
|
|
461
|
+
return options unless sub_agent
|
|
462
|
+
|
|
463
|
+
resolved = AgentHarness.configuration.resolve_sub_agent(sub_agent)
|
|
464
|
+
translated = SubAgentTranslator.for_provider(
|
|
465
|
+
self.class.provider_name,
|
|
466
|
+
resolved,
|
|
467
|
+
tool_registry: AgentHarness.configuration.tool_registry,
|
|
468
|
+
mcp_servers: AgentHarness.configuration.mcp_servers
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
options.merge(sub_agent: resolved, translated_sub_agent: translated)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def apply_sub_agent_to_prompt(prompt, translated_sub_agent)
|
|
475
|
+
return prompt unless translated_sub_agent
|
|
476
|
+
|
|
477
|
+
[translated_sub_agent[:runtime_instructions], "User task:\n#{prompt}"].join("\n\n")
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def apply_sub_agent_to_messages(messages, translated_sub_agent)
|
|
481
|
+
return messages unless translated_sub_agent
|
|
482
|
+
|
|
483
|
+
[{role: "system", content: translated_sub_agent[:runtime_instructions]}] + messages
|
|
484
|
+
end
|
|
485
|
+
|
|
449
486
|
def command_execution_options(options)
|
|
450
487
|
execution_options = {
|
|
451
488
|
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
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Shared concern for writing and cleaning up MCP configuration tempfiles.
|
|
6
|
+
#
|
|
7
|
+
# Include this module in any provider that needs to write MCP config files
|
|
8
|
+
# for CLI processes. The including class must define an +mcp_provider_key+
|
|
9
|
+
# method that returns the symbol passed to +McpConfigTranslator.for_provider+.
|
|
10
|
+
module McpConfigFileSupport
|
|
11
|
+
def write_mcp_config_file(mcp_servers, working_dir: nil)
|
|
12
|
+
require "tempfile"
|
|
13
|
+
require "tmpdir"
|
|
14
|
+
require "securerandom"
|
|
15
|
+
|
|
16
|
+
config = McpConfigTranslator.for_provider(mcp_provider_key, mcp_servers)
|
|
17
|
+
config_json = JSON.generate(config)
|
|
18
|
+
|
|
19
|
+
if @executor.is_a?(DockerCommandExecutor)
|
|
20
|
+
# When running inside a Docker container, write the config file
|
|
21
|
+
# inside the container so the CLI process can read it.
|
|
22
|
+
# Track the path so cleanup_mcp_tempfiles! can remove it after execution.
|
|
23
|
+
container_path = "/tmp/agent_harness_mcp_#{SecureRandom.hex(8)}.json"
|
|
24
|
+
result = @executor.execute(
|
|
25
|
+
["sh", "-c", "cat > #{container_path}"],
|
|
26
|
+
stdin_data: config_json,
|
|
27
|
+
timeout: 5
|
|
28
|
+
)
|
|
29
|
+
unless result.success?
|
|
30
|
+
raise McpConfigurationError,
|
|
31
|
+
"Failed to write MCP config inside container: #{result.stderr}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@mcp_docker_config_paths ||= []
|
|
35
|
+
@mcp_docker_config_paths << container_path
|
|
36
|
+
|
|
37
|
+
container_path
|
|
38
|
+
else
|
|
39
|
+
dir = working_dir || Dir.tmpdir
|
|
40
|
+
file = Tempfile.new(["agent_harness_mcp_", ".json"], dir)
|
|
41
|
+
file.write(config_json)
|
|
42
|
+
file.close
|
|
43
|
+
|
|
44
|
+
# Hold a reference so the Tempfile is not garbage-collected (and
|
|
45
|
+
# therefore deleted) before the CLI process reads it.
|
|
46
|
+
# Cleaned up by cleanup_mcp_tempfiles! after execution.
|
|
47
|
+
@mcp_config_tempfiles ||= []
|
|
48
|
+
@mcp_config_tempfiles << file
|
|
49
|
+
|
|
50
|
+
file.path
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def cleanup_mcp_tempfiles!
|
|
55
|
+
if @mcp_config_tempfiles
|
|
56
|
+
@mcp_config_tempfiles.each do |file|
|
|
57
|
+
file.close unless file.closed?
|
|
58
|
+
file.unlink
|
|
59
|
+
rescue
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
@mcp_config_tempfiles = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if @mcp_docker_config_paths
|
|
66
|
+
@mcp_docker_config_paths.each do |path|
|
|
67
|
+
@executor.execute(["rm", "-f", path], timeout: 5)
|
|
68
|
+
rescue
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
@mcp_docker_config_paths = nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentHarness
|
|
4
|
+
# Canonical provider-agnostic sub-agent definition.
|
|
5
|
+
class SubAgentConfig
|
|
6
|
+
attr_reader :name, :description, :instructions, :model, :tools, :mcp_servers
|
|
7
|
+
attr_reader :constraints, :handoff_conditions, :type, :sub_agents, :routing
|
|
8
|
+
|
|
9
|
+
def initialize(name:, description:, instructions:, model: "default", tools: [],
|
|
10
|
+
mcp_servers: [], constraints: {}, handoff_conditions: [], type: nil,
|
|
11
|
+
sub_agents: [], routing: nil)
|
|
12
|
+
@name = normalize_name(name)
|
|
13
|
+
@description = validate_string!(:description, description)
|
|
14
|
+
@instructions = validate_string!(:instructions, instructions)
|
|
15
|
+
@model = normalize_model(model)
|
|
16
|
+
@tools = normalize_array(:tools, tools)
|
|
17
|
+
@mcp_servers = normalize_array(:mcp_servers, mcp_servers)
|
|
18
|
+
@constraints = normalize_hash(:constraints, constraints)
|
|
19
|
+
@handoff_conditions = normalize_array(:handoff_conditions, handoff_conditions)
|
|
20
|
+
@type = type&.to_sym
|
|
21
|
+
@sub_agents = normalize_array(:sub_agents, sub_agents)
|
|
22
|
+
@routing = routing.nil? ? nil : normalize_hash(:routing, routing)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_hash(hash)
|
|
26
|
+
unless hash.is_a?(Hash)
|
|
27
|
+
raise ConfigurationError, "Sub-agent definition must be a Hash, got #{hash.class}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attrs = hash.each_with_object({}) do |(key, value), memo|
|
|
31
|
+
memo[key.to_sym] = value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
%i[name description instructions].each do |field|
|
|
35
|
+
value = attrs[field]
|
|
36
|
+
next if value.is_a?(String) && !value.strip.empty?
|
|
37
|
+
next if value.is_a?(Symbol)
|
|
38
|
+
|
|
39
|
+
raise ConfigurationError, "#{field} is required"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
new(**attrs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_h
|
|
46
|
+
{
|
|
47
|
+
name: @name,
|
|
48
|
+
description: @description,
|
|
49
|
+
instructions: @instructions,
|
|
50
|
+
model: @model,
|
|
51
|
+
tools: deep_dup(@tools),
|
|
52
|
+
mcp_servers: deep_dup(@mcp_servers),
|
|
53
|
+
constraints: deep_dup(@constraints),
|
|
54
|
+
handoff_conditions: deep_dup(@handoff_conditions),
|
|
55
|
+
type: @type,
|
|
56
|
+
sub_agents: deep_dup(@sub_agents),
|
|
57
|
+
routing: deep_dup(@routing)
|
|
58
|
+
}.compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def normalize_name(name)
|
|
64
|
+
value = validate_string!(:name, name)
|
|
65
|
+
value.tr(" ", "_").to_sym
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def normalize_model(model)
|
|
69
|
+
return "default" if model.nil?
|
|
70
|
+
|
|
71
|
+
validate_string!(:model, model)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_string!(field, value)
|
|
75
|
+
unless value.is_a?(String) || value.is_a?(Symbol)
|
|
76
|
+
raise ConfigurationError, "#{field} must be a String or Symbol"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
string = value.to_s.strip
|
|
80
|
+
raise ConfigurationError, "#{field} is required" if string.empty?
|
|
81
|
+
|
|
82
|
+
string
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalize_array(field, value)
|
|
86
|
+
return [].freeze if value.nil?
|
|
87
|
+
|
|
88
|
+
unless value.is_a?(Array)
|
|
89
|
+
raise ConfigurationError, "#{field} must be an Array"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
deep_dup(value).freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize_hash(field, value)
|
|
96
|
+
return {}.freeze if value.nil?
|
|
97
|
+
|
|
98
|
+
unless value.is_a?(Hash)
|
|
99
|
+
raise ConfigurationError, "#{field} must be a Hash"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
deep_dup(value).freeze
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def deep_dup(value)
|
|
106
|
+
case value
|
|
107
|
+
when Array
|
|
108
|
+
value.map { |entry| deep_dup(entry) }
|
|
109
|
+
when Hash
|
|
110
|
+
value.each_with_object({}) { |(key, entry), copy| copy[key] = deep_dup(entry) }
|
|
111
|
+
else
|
|
112
|
+
value.dup
|
|
113
|
+
end
|
|
114
|
+
rescue TypeError
|
|
115
|
+
value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module AgentHarness
|
|
6
|
+
# Loads canonical sub-agent definitions from YAML or Markdown files.
|
|
7
|
+
class SubAgentFileLoader
|
|
8
|
+
class << self
|
|
9
|
+
def load(path)
|
|
10
|
+
case File.extname(path).downcase
|
|
11
|
+
when ".yml", ".yaml"
|
|
12
|
+
load_yaml(path)
|
|
13
|
+
when ".md", ".markdown"
|
|
14
|
+
[load_markdown(path)]
|
|
15
|
+
else
|
|
16
|
+
raise ConfigurationError, "Unsupported sub-agent definition format: #{path}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def load_yaml(path)
|
|
23
|
+
parsed = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
24
|
+
unless parsed.is_a?(Hash)
|
|
25
|
+
raise ConfigurationError, "YAML sub-agent definition must be a Hash"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
agents = parsed["agents"] || parsed[:agents] || [parsed]
|
|
29
|
+
unless agents.is_a?(Array)
|
|
30
|
+
raise ConfigurationError, "YAML sub-agent definitions must provide an agents Array"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
agents.map { |entry| SubAgentConfig.from_hash(entry) }
|
|
34
|
+
rescue Psych::SyntaxError => e
|
|
35
|
+
raise ConfigurationError, "Invalid YAML in #{path}: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def load_markdown(path)
|
|
39
|
+
content = File.read(path)
|
|
40
|
+
match = content.match(/\A---\s*\n(?<frontmatter>.*?)\n---\s*\n?(?<body>.*)\z/m)
|
|
41
|
+
raise ConfigurationError, "Markdown sub-agent definitions require YAML frontmatter" unless match
|
|
42
|
+
|
|
43
|
+
attrs = YAML.safe_load(match[:frontmatter], permitted_classes: [], aliases: false) || {}
|
|
44
|
+
unless attrs.is_a?(Hash)
|
|
45
|
+
raise ConfigurationError, "Markdown frontmatter must be a Hash"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attrs["instructions"] ||= match[:body].strip
|
|
49
|
+
SubAgentConfig.from_hash(attrs)
|
|
50
|
+
rescue Psych::SyntaxError => e
|
|
51
|
+
raise ConfigurationError, "Invalid frontmatter in #{path}: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module AgentHarness
|
|
6
|
+
# Translates canonical sub-agent definitions into provider-specific formats.
|
|
7
|
+
module SubAgentTranslator
|
|
8
|
+
class << self
|
|
9
|
+
def for_provider(provider, sub_agent_config, tool_registry: AgentHarness.configuration.tool_registry,
|
|
10
|
+
mcp_servers: AgentHarness.configuration.mcp_servers)
|
|
11
|
+
config = normalize_sub_agent_config(sub_agent_config)
|
|
12
|
+
normalized_provider = normalize_provider(provider)
|
|
13
|
+
tools = resolve_tools(config.tools, provider: normalized_provider, tool_registry: tool_registry)
|
|
14
|
+
servers = resolve_mcp_servers(config.mcp_servers, mcp_servers: mcp_servers)
|
|
15
|
+
|
|
16
|
+
case normalized_provider
|
|
17
|
+
when :anthropic
|
|
18
|
+
translate_for_anthropic(config, tools: tools, mcp_servers: servers)
|
|
19
|
+
when :openai
|
|
20
|
+
translate_for_openai(config, tools: tools, mcp_servers: servers)
|
|
21
|
+
when :google
|
|
22
|
+
translate_for_google(config, tools: tools, mcp_servers: servers)
|
|
23
|
+
when :claude_code
|
|
24
|
+
translate_for_claude_code(config, tools: tools, mcp_servers: servers)
|
|
25
|
+
when :codex
|
|
26
|
+
translate_for_codex(config, tools: tools, mcp_servers: servers)
|
|
27
|
+
when :pi
|
|
28
|
+
translate_for_pi(config, tools: tools, mcp_servers: servers)
|
|
29
|
+
else
|
|
30
|
+
translate_generic(normalized_provider, config, tools: tools, mcp_servers: servers)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def normalize_sub_agent_config(sub_agent_config)
|
|
37
|
+
case sub_agent_config
|
|
38
|
+
when SubAgentConfig
|
|
39
|
+
sub_agent_config
|
|
40
|
+
when Hash
|
|
41
|
+
SubAgentConfig.from_hash(sub_agent_config)
|
|
42
|
+
else
|
|
43
|
+
raise ConfigurationError, "Sub-agent config must be a SubAgentConfig or Hash"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_provider(provider)
|
|
48
|
+
case provider.to_sym
|
|
49
|
+
when :claude then :anthropic
|
|
50
|
+
when :gemini then :google
|
|
51
|
+
else provider.to_sym
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def resolve_tools(tool_refs, provider:, tool_registry:)
|
|
56
|
+
tool_refs.map do |tool|
|
|
57
|
+
case tool
|
|
58
|
+
when Symbol, String
|
|
59
|
+
mapping = tool_registry.fetch(tool).mapping_for(provider)
|
|
60
|
+
mapping.nil? ? tool.to_s : mapping
|
|
61
|
+
when Hash
|
|
62
|
+
deep_dup(tool)
|
|
63
|
+
else
|
|
64
|
+
raise ConfigurationError, "Unsupported tool reference #{tool.inspect} in sub-agent definition"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_mcp_servers(server_refs, mcp_servers:)
|
|
70
|
+
server_refs.map do |server|
|
|
71
|
+
resolved = case server
|
|
72
|
+
when McpServer
|
|
73
|
+
server
|
|
74
|
+
when Symbol, String
|
|
75
|
+
mcp_servers.fetch(server.to_sym) do
|
|
76
|
+
raise ConfigurationError, "Unknown MCP server: #{server}"
|
|
77
|
+
end
|
|
78
|
+
when Hash
|
|
79
|
+
McpServer.from_hash(server)
|
|
80
|
+
else
|
|
81
|
+
raise ConfigurationError, "Unsupported MCP server reference #{server.inspect} in sub-agent definition"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
resolved.to_h
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def translate_for_anthropic(config, tools:, mcp_servers:)
|
|
89
|
+
{
|
|
90
|
+
provider: :anthropic,
|
|
91
|
+
format: :agent_sdk,
|
|
92
|
+
agent: {
|
|
93
|
+
name: config.name.to_s,
|
|
94
|
+
description: config.description,
|
|
95
|
+
instructions: config.instructions,
|
|
96
|
+
model: config.model,
|
|
97
|
+
tools: tools,
|
|
98
|
+
mcp_servers: mcp_servers,
|
|
99
|
+
constraints: deep_dup(config.constraints)
|
|
100
|
+
},
|
|
101
|
+
runtime_instructions: runtime_instructions(config)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def translate_for_openai(config, tools:, mcp_servers:)
|
|
106
|
+
{
|
|
107
|
+
provider: :openai,
|
|
108
|
+
format: :responses,
|
|
109
|
+
assistant: {
|
|
110
|
+
name: config.name.to_s,
|
|
111
|
+
description: config.description,
|
|
112
|
+
instructions: config.instructions,
|
|
113
|
+
model: config.model,
|
|
114
|
+
tools: tools,
|
|
115
|
+
metadata: {
|
|
116
|
+
mcp_servers: mcp_servers,
|
|
117
|
+
constraints: deep_dup(config.constraints)
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
runtime_instructions: runtime_instructions(config)
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def translate_for_google(config, tools:, mcp_servers:)
|
|
125
|
+
{
|
|
126
|
+
provider: :google,
|
|
127
|
+
format: :adk,
|
|
128
|
+
agent: {
|
|
129
|
+
name: config.name.to_s,
|
|
130
|
+
description: config.description,
|
|
131
|
+
instruction: config.instructions,
|
|
132
|
+
model: config.model,
|
|
133
|
+
tools: tools,
|
|
134
|
+
mcp_servers: mcp_servers,
|
|
135
|
+
constraints: deep_dup(config.constraints)
|
|
136
|
+
},
|
|
137
|
+
runtime_instructions: runtime_instructions(config)
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def translate_for_claude_code(config, tools:, mcp_servers:)
|
|
142
|
+
frontmatter = {
|
|
143
|
+
"name" => config.name.to_s,
|
|
144
|
+
"description" => config.description,
|
|
145
|
+
"model" => config.model,
|
|
146
|
+
"tools" => tools,
|
|
147
|
+
"mcp_servers" => mcp_servers,
|
|
148
|
+
"constraints" => deep_dup(config.constraints)
|
|
149
|
+
}.delete_if { |_key, value| value.respond_to?(:empty?) ? value.empty? : value.nil? }
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
provider: :claude_code,
|
|
153
|
+
format: :markdown,
|
|
154
|
+
content: build_markdown_definition(frontmatter, config.instructions),
|
|
155
|
+
runtime_instructions: runtime_instructions(config)
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def translate_for_codex(config, tools:, mcp_servers:)
|
|
160
|
+
{
|
|
161
|
+
provider: :codex,
|
|
162
|
+
format: :delegated_prompt,
|
|
163
|
+
definition: {
|
|
164
|
+
name: config.name.to_s,
|
|
165
|
+
description: config.description,
|
|
166
|
+
instructions: config.instructions,
|
|
167
|
+
model: config.model,
|
|
168
|
+
tools: tools,
|
|
169
|
+
mcp_servers: mcp_servers,
|
|
170
|
+
constraints: deep_dup(config.constraints)
|
|
171
|
+
},
|
|
172
|
+
runtime_instructions: runtime_instructions(config)
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def translate_for_pi(config, tools:, mcp_servers:)
|
|
177
|
+
frontmatter = {
|
|
178
|
+
"name" => config.name.to_s,
|
|
179
|
+
"description" => config.description,
|
|
180
|
+
"model" => config.model,
|
|
181
|
+
"tools" => tools,
|
|
182
|
+
"mcp_servers" => mcp_servers
|
|
183
|
+
}.delete_if { |_key, value| value.respond_to?(:empty?) ? value.empty? : value.nil? }
|
|
184
|
+
|
|
185
|
+
{
|
|
186
|
+
provider: :pi,
|
|
187
|
+
format: :skill_markdown,
|
|
188
|
+
content: build_markdown_definition(frontmatter, config.instructions),
|
|
189
|
+
runtime_instructions: runtime_instructions(config)
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def translate_generic(provider, config, tools:, mcp_servers:)
|
|
194
|
+
{
|
|
195
|
+
provider: provider,
|
|
196
|
+
format: :generic,
|
|
197
|
+
definition: {
|
|
198
|
+
name: config.name.to_s,
|
|
199
|
+
description: config.description,
|
|
200
|
+
instructions: config.instructions,
|
|
201
|
+
model: config.model,
|
|
202
|
+
tools: tools,
|
|
203
|
+
mcp_servers: mcp_servers,
|
|
204
|
+
constraints: deep_dup(config.constraints)
|
|
205
|
+
},
|
|
206
|
+
runtime_instructions: runtime_instructions(config)
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def runtime_instructions(config)
|
|
211
|
+
<<~TEXT.strip
|
|
212
|
+
Sub-agent role: #{config.name}
|
|
213
|
+
Description: #{config.description}
|
|
214
|
+
|
|
215
|
+
Follow these sub-agent instructions exactly:
|
|
216
|
+
#{config.instructions}
|
|
217
|
+
TEXT
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def build_markdown_definition(frontmatter, instructions)
|
|
221
|
+
<<~MARKDOWN
|
|
222
|
+
---
|
|
223
|
+
#{YAML.dump(frontmatter).sub(/\A---\s*\n/, "").strip}
|
|
224
|
+
---
|
|
225
|
+
#{instructions}
|
|
226
|
+
MARKDOWN
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def deep_dup(value)
|
|
230
|
+
case value
|
|
231
|
+
when Array
|
|
232
|
+
value.map { |entry| deep_dup(entry) }
|
|
233
|
+
when Hash
|
|
234
|
+
value.each_with_object({}) { |(key, entry), copy| copy[key] = deep_dup(entry) }
|
|
235
|
+
else
|
|
236
|
+
value.dup
|
|
237
|
+
end
|
|
238
|
+
rescue TypeError
|
|
239
|
+
value
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
data/lib/agent_harness.rb
CHANGED
|
@@ -77,6 +77,23 @@ module AgentHarness
|
|
|
77
77
|
conductor.send_message(prompt, provider: provider, executor: executor, **options)
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# Resolve a canonical sub-agent definition by name or inline payload.
|
|
81
|
+
#
|
|
82
|
+
# @param reference [Symbol, String, Hash, SubAgentConfig]
|
|
83
|
+
# @return [SubAgentConfig]
|
|
84
|
+
def sub_agent(reference)
|
|
85
|
+
configuration.resolve_sub_agent(reference)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Translate a canonical sub-agent definition into a provider-specific format.
|
|
89
|
+
#
|
|
90
|
+
# @param reference [Symbol, String, Hash, SubAgentConfig] sub-agent reference
|
|
91
|
+
# @param provider [Symbol, String] target provider
|
|
92
|
+
# @return [Hash] provider-specific sub-agent definition
|
|
93
|
+
def translate_sub_agent(reference, provider:)
|
|
94
|
+
SubAgentTranslator.for_provider(provider, sub_agent(reference))
|
|
95
|
+
end
|
|
96
|
+
|
|
80
97
|
# Get a provider instance
|
|
81
98
|
# @param name [Symbol] the provider name
|
|
82
99
|
# @return [Providers::Base] the provider instance
|
|
@@ -261,6 +278,11 @@ end
|
|
|
261
278
|
# Core components
|
|
262
279
|
require_relative "agent_harness/errors"
|
|
263
280
|
require_relative "agent_harness/mcp_server"
|
|
281
|
+
require_relative "agent_harness/mcp_config_loader"
|
|
282
|
+
require_relative "agent_harness/mcp_config_translator"
|
|
283
|
+
require_relative "agent_harness/sub_agent_config"
|
|
284
|
+
require_relative "agent_harness/sub_agent_file_loader"
|
|
285
|
+
require_relative "agent_harness/sub_agent_translator"
|
|
264
286
|
require_relative "agent_harness/provider_runtime"
|
|
265
287
|
require_relative "agent_harness/execution_preparation"
|
|
266
288
|
require_relative "agent_harness/configuration"
|
|
@@ -281,6 +303,7 @@ require_relative "agent_harness/providers/adapter"
|
|
|
281
303
|
require_relative "agent_harness/providers/base"
|
|
282
304
|
require_relative "agent_harness/providers/token_usage_parsing"
|
|
283
305
|
require_relative "agent_harness/providers/rate_limit_reset_parsing"
|
|
306
|
+
require_relative "agent_harness/providers/mcp_config_file_support"
|
|
284
307
|
require_relative "agent_harness/providers/anthropic"
|
|
285
308
|
require_relative "agent_harness/providers/aider"
|
|
286
309
|
require_relative "agent_harness/providers/codex"
|
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.11.
|
|
4
|
+
version: 0.11.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bart Agapinan
|
|
@@ -107,6 +107,8 @@ files:
|
|
|
107
107
|
- lib/agent_harness/error_taxonomy.rb
|
|
108
108
|
- lib/agent_harness/errors.rb
|
|
109
109
|
- lib/agent_harness/execution_preparation.rb
|
|
110
|
+
- lib/agent_harness/mcp_config_loader.rb
|
|
111
|
+
- lib/agent_harness/mcp_config_translator.rb
|
|
110
112
|
- lib/agent_harness/mcp_server.rb
|
|
111
113
|
- lib/agent_harness/openai_compatible_transport.rb
|
|
112
114
|
- lib/agent_harness/orchestration/circuit_breaker.rb
|
|
@@ -126,12 +128,16 @@ files:
|
|
|
126
128
|
- lib/agent_harness/providers/gemini.rb
|
|
127
129
|
- lib/agent_harness/providers/github_copilot.rb
|
|
128
130
|
- lib/agent_harness/providers/kilocode.rb
|
|
131
|
+
- lib/agent_harness/providers/mcp_config_file_support.rb
|
|
129
132
|
- lib/agent_harness/providers/mistral_vibe.rb
|
|
130
133
|
- lib/agent_harness/providers/opencode.rb
|
|
131
134
|
- lib/agent_harness/providers/rate_limit_reset_parsing.rb
|
|
132
135
|
- lib/agent_harness/providers/registry.rb
|
|
133
136
|
- lib/agent_harness/providers/token_usage_parsing.rb
|
|
134
137
|
- lib/agent_harness/response.rb
|
|
138
|
+
- lib/agent_harness/sub_agent_config.rb
|
|
139
|
+
- lib/agent_harness/sub_agent_file_loader.rb
|
|
140
|
+
- lib/agent_harness/sub_agent_translator.rb
|
|
135
141
|
- lib/agent_harness/text_transport.rb
|
|
136
142
|
- lib/agent_harness/token_tracker.rb
|
|
137
143
|
- lib/agent_harness/version.rb
|