agent-harness 0.17.2 → 0.18.0
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 +13 -1
- data/lib/agent_harness/mcp_config_translator.rb +27 -0
- data/lib/agent_harness/provider_runtime.rb +58 -0
- data/lib/agent_harness/providers/adapter.rb +9 -0
- data/lib/agent_harness/providers/aider.rb +8 -2
- data/lib/agent_harness/providers/anthropic.rb +4 -1
- data/lib/agent_harness/providers/base.rb +229 -15
- data/lib/agent_harness/providers/cursor.rb +8 -2
- data/lib/agent_harness/providers/github_copilot.rb +314 -455
- data/lib/agent_harness/skill.rb +214 -0
- data/lib/agent_harness/skills.rb +98 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +3 -0
- metadata +3 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module AgentHarness
|
|
6
|
+
# Canonical provider-agnostic skill definition loaded from SKILL.md.
|
|
7
|
+
class Skill
|
|
8
|
+
PROVIDER_FAMILY_ALIASES = {
|
|
9
|
+
anthropic: :anthropic,
|
|
10
|
+
google: :google,
|
|
11
|
+
openai: :openai,
|
|
12
|
+
openai_compatible: :openai
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :name, :description, :instructions, :trigger, :tools, :mcp_servers
|
|
16
|
+
attr_reader :provider_overrides, :source_path
|
|
17
|
+
|
|
18
|
+
def initialize(name:, description:, instructions:, trigger: nil, tools: [], mcp_servers: [],
|
|
19
|
+
providers: {}, source_path: nil)
|
|
20
|
+
@name = normalize_name(name)
|
|
21
|
+
@description = validate_string!(:description, description)
|
|
22
|
+
@instructions = validate_string!(:instructions, instructions)
|
|
23
|
+
@trigger = trigger.nil? ? nil : validate_string!(:trigger, trigger)
|
|
24
|
+
@tools = normalize_array(:tools, tools)
|
|
25
|
+
@mcp_servers = normalize_mcp_servers(mcp_servers)
|
|
26
|
+
@provider_overrides = normalize_provider_overrides(providers)
|
|
27
|
+
@source_path = source_path && File.expand_path(source_path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.from_hash(hash = nil, source_path: nil, **kwargs)
|
|
31
|
+
hash = kwargs if hash.nil? && !kwargs.empty?
|
|
32
|
+
|
|
33
|
+
unless hash.is_a?(Hash)
|
|
34
|
+
raise ConfigurationError, "Skill definition must be a Hash, got #{hash.class}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attrs = hash.each_with_object({}) do |(key, value), memo|
|
|
38
|
+
memo[key.to_sym] = value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
%i[name description instructions].each do |field|
|
|
42
|
+
value = attrs[field]
|
|
43
|
+
next if value.is_a?(String) && !value.strip.empty?
|
|
44
|
+
next if value.is_a?(Symbol)
|
|
45
|
+
|
|
46
|
+
raise ConfigurationError, "#{field} is required"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
new(**attrs.merge(source_path: source_path))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.load_file(path)
|
|
53
|
+
expanded_path = File.expand_path(path)
|
|
54
|
+
content = File.read(expanded_path)
|
|
55
|
+
frontmatter, body = parse_markdown(content)
|
|
56
|
+
from_hash(frontmatter.merge(instructions: body), source_path: expanded_path)
|
|
57
|
+
rescue Errno::ENOENT
|
|
58
|
+
raise ConfigurationError, "Skill file not found: #{path}"
|
|
59
|
+
rescue Psych::Exception => e
|
|
60
|
+
raise ConfigurationError, "Invalid YAML frontmatter in skill #{path}: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def provider_override_for(provider)
|
|
64
|
+
merged = provider_override_keys_for(provider).reduce(nil) do |runtime, key|
|
|
65
|
+
override = @provider_overrides[key]
|
|
66
|
+
next runtime unless override
|
|
67
|
+
|
|
68
|
+
runtime ? runtime.merge(override) : ProviderRuntime.wrap(override)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
merged ? merged.to_h : {}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
name: @name,
|
|
77
|
+
description: @description,
|
|
78
|
+
instructions: @instructions,
|
|
79
|
+
trigger: @trigger,
|
|
80
|
+
tools: deep_dup(@tools),
|
|
81
|
+
mcp_servers: deep_dup(@mcp_servers),
|
|
82
|
+
providers: deep_dup(@provider_overrides),
|
|
83
|
+
source_path: @source_path
|
|
84
|
+
}.compact
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def self.parse_markdown(content)
|
|
90
|
+
match = content.match(/\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m)
|
|
91
|
+
raise ConfigurationError, "Skill markdown must begin with YAML frontmatter" unless match
|
|
92
|
+
|
|
93
|
+
frontmatter = YAML.safe_load(match[1], permitted_classes: [], aliases: false) || {}
|
|
94
|
+
unless frontmatter.is_a?(Hash)
|
|
95
|
+
raise ConfigurationError, "Skill frontmatter must be a Hash"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
[frontmatter, match[2].to_s.strip]
|
|
99
|
+
end
|
|
100
|
+
private_class_method :parse_markdown
|
|
101
|
+
|
|
102
|
+
def normalize_name(name)
|
|
103
|
+
value = validate_string!(:name, name)
|
|
104
|
+
value.tr(" -", "__").to_sym
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_string!(field, value)
|
|
108
|
+
unless value.is_a?(String) || value.is_a?(Symbol)
|
|
109
|
+
raise ConfigurationError, "#{field} must be a String or Symbol"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
string = value.to_s.strip
|
|
113
|
+
raise ConfigurationError, "#{field} is required" if string.empty?
|
|
114
|
+
|
|
115
|
+
string
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_array(field, value)
|
|
119
|
+
return [].freeze if value.nil?
|
|
120
|
+
|
|
121
|
+
unless value.is_a?(Array)
|
|
122
|
+
raise ConfigurationError, "#{field} must be an Array"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
deep_dup(value).freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def normalize_mcp_servers(mcp_servers)
|
|
129
|
+
normalize_array(:mcp_servers, mcp_servers).map do |server|
|
|
130
|
+
case server
|
|
131
|
+
when McpServer
|
|
132
|
+
server.to_h.freeze
|
|
133
|
+
when Hash
|
|
134
|
+
McpServer.from_hash(server).to_h.freeze
|
|
135
|
+
else
|
|
136
|
+
raise ConfigurationError, "Unsupported MCP server reference #{server.inspect} in skill definition"
|
|
137
|
+
end
|
|
138
|
+
end.freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def normalize_provider_overrides(providers)
|
|
142
|
+
return {}.freeze if providers.nil?
|
|
143
|
+
|
|
144
|
+
unless providers.is_a?(Hash)
|
|
145
|
+
raise ConfigurationError, "providers must be a Hash"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
providers.each_with_object({}) do |(key, value), memo|
|
|
149
|
+
provider_key = normalize_provider_key(key)
|
|
150
|
+
memo[provider_key] = normalize_provider_override_value(provider_key, value)
|
|
151
|
+
end.freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def normalize_provider_override_value(provider_key, value)
|
|
155
|
+
case value
|
|
156
|
+
when true
|
|
157
|
+
(provider_key == :all) ? nil : {}
|
|
158
|
+
when nil
|
|
159
|
+
{}
|
|
160
|
+
when Hash
|
|
161
|
+
deep_dup(value).transform_keys(&:to_sym).freeze
|
|
162
|
+
else
|
|
163
|
+
raise ConfigurationError, "providers.#{provider_key} must be true or a Hash"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def normalize_provider_key(provider)
|
|
168
|
+
key = provider.to_sym
|
|
169
|
+
return :all if key == :all
|
|
170
|
+
|
|
171
|
+
return PROVIDER_FAMILY_ALIASES[key] if PROVIDER_FAMILY_ALIASES.key?(key)
|
|
172
|
+
|
|
173
|
+
registry = Providers::Registry.instance
|
|
174
|
+
canonical = registry.canonical_name(key)
|
|
175
|
+
raise ConfigurationError, "Unknown provider in skill definition: #{provider}" unless registry.registered?(canonical)
|
|
176
|
+
|
|
177
|
+
canonical.to_sym
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def provider_override_keys_for(provider)
|
|
181
|
+
key = provider.to_sym
|
|
182
|
+
return [:all] if key == :all
|
|
183
|
+
|
|
184
|
+
registry = Providers::Registry.instance
|
|
185
|
+
canonical = registry.canonical_name(key)
|
|
186
|
+
concrete = registry.registered?(canonical) ? canonical.to_sym : key
|
|
187
|
+
family = normalize_provider_family(concrete)
|
|
188
|
+
|
|
189
|
+
[:all, family, concrete].uniq
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def normalize_provider_family(provider)
|
|
193
|
+
case provider
|
|
194
|
+
when :claude then :anthropic
|
|
195
|
+
when :gemini then :google
|
|
196
|
+
when :cursor, :github_copilot, :codex, :opencode, :openai_compatible then :openai
|
|
197
|
+
else provider
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def deep_dup(value)
|
|
202
|
+
case value
|
|
203
|
+
when Array
|
|
204
|
+
value.map { |entry| deep_dup(entry) }
|
|
205
|
+
when Hash
|
|
206
|
+
value.each_with_object({}) { |(key, entry), copy| copy[key] = deep_dup(entry) }
|
|
207
|
+
else
|
|
208
|
+
value.dup
|
|
209
|
+
end
|
|
210
|
+
rescue TypeError
|
|
211
|
+
value
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentHarness
|
|
4
|
+
# Filesystem-backed registry for reusable agent skills.
|
|
5
|
+
module Skills
|
|
6
|
+
AGENT_HARNESS_SKILLS_DIR = File.join(".agent-harness", "skills")
|
|
7
|
+
SHARED_SKILLS_DIR = File.join(".agents", "skills")
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def discover(cwd: Dir.pwd, home: Dir.home, refresh: false)
|
|
11
|
+
cache_key = [File.expand_path(cwd), File.expand_path(home)]
|
|
12
|
+
@discovered ||= {}
|
|
13
|
+
@registry ||= {}
|
|
14
|
+
@discovered.delete(cache_key) if refresh
|
|
15
|
+
@discovered[cache_key] ||= discover_registry(cwd: cwd, home: home)
|
|
16
|
+
|
|
17
|
+
combined_registry(cache_key).values
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def find(name, cwd: Dir.pwd, home: Dir.home)
|
|
21
|
+
cache_key = [File.expand_path(cwd), File.expand_path(home)]
|
|
22
|
+
discover(cwd: cwd, home: home)
|
|
23
|
+
|
|
24
|
+
combined_registry(cache_key).fetch(normalize_lookup_key(name)) do
|
|
25
|
+
raise ConfigurationError, "Unknown skill: #{name}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def register(name, attributes)
|
|
30
|
+
@registry ||= {}
|
|
31
|
+
skill = case attributes
|
|
32
|
+
when Skill
|
|
33
|
+
attributes
|
|
34
|
+
when Hash
|
|
35
|
+
Skill.from_hash(attributes.merge(name: name))
|
|
36
|
+
else
|
|
37
|
+
raise ConfigurationError, "Skill registration must be a Skill or Hash"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@registry[skill.name] = skill
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve(reference, cwd: Dir.pwd, home: Dir.home)
|
|
44
|
+
case reference
|
|
45
|
+
when nil
|
|
46
|
+
nil
|
|
47
|
+
when Skill
|
|
48
|
+
reference
|
|
49
|
+
when Hash
|
|
50
|
+
Skill.from_hash(reference)
|
|
51
|
+
else
|
|
52
|
+
find(reference, cwd: cwd, home: home)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolve_all(references, cwd: Dir.pwd, home: Dir.home)
|
|
57
|
+
Array(references).filter_map { |reference| resolve(reference, cwd: cwd, home: home) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reset!
|
|
61
|
+
@registry = {}
|
|
62
|
+
@discovered = {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def normalize_lookup_key(name)
|
|
68
|
+
name.to_s.tr(" -", "__").to_sym
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def combined_registry(cache_key)
|
|
72
|
+
discovered_registry = @discovered.fetch(cache_key)
|
|
73
|
+
discovered_registry.merge(@registry || {})
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def discover_registry(cwd:, home:)
|
|
77
|
+
skill_paths_for(cwd: cwd, home: home).each_with_object({}) do |path, memo|
|
|
78
|
+
skill = Skill.load_file(path)
|
|
79
|
+
memo[skill.name] = skill
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def skill_paths_for(cwd:, home:)
|
|
84
|
+
[
|
|
85
|
+
File.join(File.expand_path(home), AGENT_HARNESS_SKILLS_DIR),
|
|
86
|
+
File.join(File.expand_path(cwd), SHARED_SKILLS_DIR),
|
|
87
|
+
File.join(File.expand_path(cwd), AGENT_HARNESS_SKILLS_DIR)
|
|
88
|
+
].flat_map do |directory|
|
|
89
|
+
next [] unless Dir.exist?(directory)
|
|
90
|
+
|
|
91
|
+
direct_skill = File.join(directory, "SKILL.md")
|
|
92
|
+
nested_skills = Dir.glob(File.join(directory, "*", "SKILL.md")).sort
|
|
93
|
+
([direct_skill] + nested_skills).select { |path| File.file?(path) }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/agent_harness.rb
CHANGED
|
@@ -47,6 +47,7 @@ module AgentHarness
|
|
|
47
47
|
@configuration = nil
|
|
48
48
|
@conductor = nil
|
|
49
49
|
@token_tracker = nil
|
|
50
|
+
Skills.reset! if defined?(Skills)
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
# Returns the global logger
|
|
@@ -346,6 +347,8 @@ end
|
|
|
346
347
|
# Core components
|
|
347
348
|
require_relative "agent_harness/errors"
|
|
348
349
|
require_relative "agent_harness/extensions"
|
|
350
|
+
require_relative "agent_harness/skill"
|
|
351
|
+
require_relative "agent_harness/skills"
|
|
349
352
|
require_relative "agent_harness/mcp_server"
|
|
350
353
|
require_relative "agent_harness/mcp_config_loader"
|
|
351
354
|
require_relative "agent_harness/mcp_config_translator"
|
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.
|
|
4
|
+
version: 0.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bart Agapinan
|
|
@@ -138,6 +138,8 @@ files:
|
|
|
138
138
|
- lib/agent_harness/providers/registry.rb
|
|
139
139
|
- lib/agent_harness/providers/token_usage_parsing.rb
|
|
140
140
|
- lib/agent_harness/response.rb
|
|
141
|
+
- lib/agent_harness/skill.rb
|
|
142
|
+
- lib/agent_harness/skills.rb
|
|
141
143
|
- lib/agent_harness/sub_agent_config.rb
|
|
142
144
|
- lib/agent_harness/sub_agent_file_loader.rb
|
|
143
145
|
- lib/agent_harness/sub_agent_translator.rb
|