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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.17.2"
4
+ VERSION = "0.18.0"
5
5
  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.17.2
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