agent-harness 0.17.2 → 0.17.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 +7 -0
- data/lib/agent_harness/configuration.rb +13 -1
- 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 +8 -2
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5eee3cd4f4e77fe648ed70a6c31c29af4959b90d7fb848534ecdf75558811d0f
|
|
4
|
+
data.tar.gz: 8eca8a991857a8518bc9b192d78984bb459470725da9727ac830fd42eaf2a91b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3d2b6cd29f4b9607b52b935d323db5217514c3e32b213f222c3fd2b4731e2407315854bdbbdd2e4857c84302b4ae21e844e611bff11e24f051a28f035da93db
|
|
7
|
+
data.tar.gz: 979df7fc6616509e14593d0fee2f189ec4a5d7b3c34d071fe755fab31115bf932f90484f3e1483dbabad956383c8bc1528174ec08791bc32a186d814295d87ef
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.17.3](https://github.com/viamin/agent-harness/compare/agent-harness/v0.17.2...agent-harness/v0.17.3) (2026-05-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* 161: Support provider-agnostic skills system ([#204](https://github.com/viamin/agent-harness/issues/204)) ([20a6ed5](https://github.com/viamin/agent-harness/commit/20a6ed5a8e6701ad7730d88b8037145d86b39c37))
|
|
9
|
+
|
|
3
10
|
## [0.17.2](https://github.com/viamin/agent-harness/compare/agent-harness/v0.17.1...agent-harness/v0.17.2) (2026-05-05)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -420,7 +420,10 @@ module AgentHarness
|
|
|
420
420
|
end
|
|
421
421
|
|
|
422
422
|
def mapping_for(provider)
|
|
423
|
-
|
|
423
|
+
provider_key = provider.to_sym
|
|
424
|
+
mapping = @provider_mappings[provider_key]
|
|
425
|
+
mapping ||= @provider_mappings[normalize_provider_family(provider_key)]
|
|
426
|
+
deep_dup(mapping)
|
|
424
427
|
end
|
|
425
428
|
|
|
426
429
|
def to_h
|
|
@@ -429,6 +432,15 @@ module AgentHarness
|
|
|
429
432
|
|
|
430
433
|
private
|
|
431
434
|
|
|
435
|
+
def normalize_provider_family(provider)
|
|
436
|
+
case provider
|
|
437
|
+
when :claude then :anthropic
|
|
438
|
+
when :gemini then :google
|
|
439
|
+
when :cursor, :github_copilot, :codex, :opencode, :openai_compatible then :openai
|
|
440
|
+
else provider
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
432
444
|
def deep_dup(value)
|
|
433
445
|
case value
|
|
434
446
|
when Array
|
|
@@ -174,6 +174,43 @@ module AgentHarness
|
|
|
174
174
|
chat_tools.nil?
|
|
175
175
|
end
|
|
176
176
|
|
|
177
|
+
def to_h
|
|
178
|
+
{
|
|
179
|
+
model: model,
|
|
180
|
+
base_url: base_url,
|
|
181
|
+
api_provider: api_provider,
|
|
182
|
+
env: env.empty? ? nil : env.dup,
|
|
183
|
+
flags: flags.empty? ? nil : flags.dup,
|
|
184
|
+
unset_env: unset_env.empty? ? nil : unset_env.dup,
|
|
185
|
+
metadata: metadata.empty? ? nil : metadata.dup,
|
|
186
|
+
chat_base_url: chat_base_url,
|
|
187
|
+
chat_model: chat_model,
|
|
188
|
+
chat_api_key: chat_api_key,
|
|
189
|
+
chat_max_tokens: chat_max_tokens,
|
|
190
|
+
chat_tools: chat_tools&.dup
|
|
191
|
+
}.compact
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def merge(other)
|
|
195
|
+
other_runtime = ProviderRuntime.wrap(other)
|
|
196
|
+
return self if other_runtime.nil? || other_runtime.empty?
|
|
197
|
+
|
|
198
|
+
ProviderRuntime.new(
|
|
199
|
+
model: other_runtime.model || model,
|
|
200
|
+
base_url: other_runtime.base_url || base_url,
|
|
201
|
+
api_provider: other_runtime.api_provider || api_provider,
|
|
202
|
+
env: env.merge(other_runtime.env),
|
|
203
|
+
flags: flags + other_runtime.flags,
|
|
204
|
+
unset_env: unset_env + other_runtime.unset_env,
|
|
205
|
+
metadata: metadata.merge(other_runtime.metadata),
|
|
206
|
+
chat_base_url: other_runtime.chat_base_url || chat_base_url,
|
|
207
|
+
chat_model: other_runtime.chat_model || chat_model,
|
|
208
|
+
chat_api_key: other_runtime.chat_api_key || chat_api_key,
|
|
209
|
+
chat_max_tokens: other_runtime.chat_max_tokens.nil? ? chat_max_tokens : other_runtime.chat_max_tokens,
|
|
210
|
+
chat_tools: merge_chat_tools(chat_tools, other_runtime.chat_tools)
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
177
214
|
private_class_method def self.hash_value(hash, key)
|
|
178
215
|
sym_value = hash[key]
|
|
179
216
|
str_value = hash[key.to_s]
|
|
@@ -185,6 +222,27 @@ module AgentHarness
|
|
|
185
222
|
|
|
186
223
|
private
|
|
187
224
|
|
|
225
|
+
def merge_chat_tools(base_tools, override_tools)
|
|
226
|
+
return override_tools unless base_tools
|
|
227
|
+
return base_tools unless override_tools
|
|
228
|
+
|
|
229
|
+
merged = base_tools + override_tools
|
|
230
|
+
names = merged.map { |t| tool_name(t) }.compact
|
|
231
|
+
duplicates = names.group_by { |n| n }.select { |_, v| v.size > 1 }.keys
|
|
232
|
+
unless duplicates.empty?
|
|
233
|
+
raise AgentHarness::ConfigurationError,
|
|
234
|
+
"Duplicate chat tool names across merged runtimes: #{duplicates.join(", ")}"
|
|
235
|
+
end
|
|
236
|
+
merged
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def tool_name(tool)
|
|
240
|
+
return unless tool.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
tool[:name] || tool["name"] ||
|
|
243
|
+
tool.dig(:function, :name) || tool.dig("function", "name")
|
|
244
|
+
end
|
|
245
|
+
|
|
188
246
|
def validate_optional_string!(name, value)
|
|
189
247
|
return if value.nil?
|
|
190
248
|
return if value.is_a?(String)
|
|
@@ -950,6 +950,15 @@ module AgentHarness
|
|
|
950
950
|
false
|
|
951
951
|
end
|
|
952
952
|
|
|
953
|
+
# Check if provider message mode can inject available tool definitions
|
|
954
|
+
# through the +tools:+ option. Providers that use +tools:+ strictly for
|
|
955
|
+
# disallow lists should leave this as +false+.
|
|
956
|
+
#
|
|
957
|
+
# @return [Boolean] true if skill tools can be merged into message mode
|
|
958
|
+
def supports_message_tool_injection?
|
|
959
|
+
false
|
|
960
|
+
end
|
|
961
|
+
|
|
953
962
|
# Check if provider supports text-only mode via direct HTTP transport.
|
|
954
963
|
#
|
|
955
964
|
# Providers that return +true+ will route +mode: :text+ requests
|
|
@@ -211,6 +211,9 @@ module AgentHarness
|
|
|
211
211
|
log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
|
|
212
212
|
|
|
213
213
|
options = normalize_provider_runtime(options)
|
|
214
|
+
skill_context = resolve_skills(options)
|
|
215
|
+
prompt = apply_skills_to_prompt(prompt, skill_context)
|
|
216
|
+
options = skill_context[:options]
|
|
214
217
|
runtime = options[:provider_runtime]
|
|
215
218
|
|
|
216
219
|
options = normalize_mcp_servers(options)
|
|
@@ -255,7 +258,7 @@ module AgentHarness
|
|
|
255
258
|
log_debug("send_message_complete", duration: duration, tokens: response.tokens)
|
|
256
259
|
|
|
257
260
|
response
|
|
258
|
-
rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
261
|
+
rescue ConfigurationError, McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
259
262
|
raise
|
|
260
263
|
rescue => e
|
|
261
264
|
handle_error(e, prompt: prompt, options: options)
|
|
@@ -267,6 +270,9 @@ module AgentHarness
|
|
|
267
270
|
log_debug("plan_execution_start", prompt_length: prompt.length, options: options.keys)
|
|
268
271
|
|
|
269
272
|
options = normalize_provider_runtime(options)
|
|
273
|
+
skill_context = resolve_skills(options)
|
|
274
|
+
prompt = apply_skills_to_prompt(prompt, skill_context)
|
|
275
|
+
options = skill_context[:options]
|
|
270
276
|
options = normalize_mcp_servers(options)
|
|
271
277
|
validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
|
|
272
278
|
|
|
@@ -277,7 +283,7 @@ module AgentHarness
|
|
|
277
283
|
env: build_env(options),
|
|
278
284
|
preparation: build_execution_preparation(options)
|
|
279
285
|
}
|
|
280
|
-
rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
286
|
+
rescue ConfigurationError, McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
281
287
|
raise
|
|
282
288
|
rescue => e
|
|
283
289
|
handle_error(e, prompt: prompt, options: options)
|
|
@@ -378,7 +378,10 @@ module AgentHarness
|
|
|
378
378
|
|
|
379
379
|
def send_message(prompt:, **options)
|
|
380
380
|
if options[:mode] == :text
|
|
381
|
-
|
|
381
|
+
options = normalize_provider_runtime(options)
|
|
382
|
+
skill_context = resolve_skills(options)
|
|
383
|
+
prompt = apply_skills_to_prompt(prompt, skill_context)
|
|
384
|
+
return send_text_message(prompt, **skill_context[:options].except(:mode, :skills))
|
|
382
385
|
end
|
|
383
386
|
|
|
384
387
|
super
|
|
@@ -129,6 +129,9 @@ module AgentHarness
|
|
|
129
129
|
|
|
130
130
|
# Coerce provider_runtime from Hash if needed
|
|
131
131
|
options = normalize_provider_runtime(options)
|
|
132
|
+
skill_context = resolve_skills(options)
|
|
133
|
+
prompt = apply_skills_to_prompt(prompt, skill_context)
|
|
134
|
+
options = skill_context[:options]
|
|
132
135
|
|
|
133
136
|
# Capture execution options (callbacks, observer) before extensions
|
|
134
137
|
# processing deep-dups the options hash, which would replace identity-
|
|
@@ -191,7 +194,8 @@ module AgentHarness
|
|
|
191
194
|
log_debug("send_message_complete", duration: duration, tokens: response.tokens)
|
|
192
195
|
|
|
193
196
|
response
|
|
194
|
-
rescue ExtensionCompatibilityError, McpConfigurationError, McpUnsupportedError,
|
|
197
|
+
rescue ExtensionCompatibilityError, ConfigurationError, McpConfigurationError, McpUnsupportedError,
|
|
198
|
+
McpTransportUnsupportedError
|
|
195
199
|
raise
|
|
196
200
|
rescue => e
|
|
197
201
|
handle_error(e, prompt: prompt, options: options)
|
|
@@ -221,6 +225,9 @@ module AgentHarness
|
|
|
221
225
|
end
|
|
222
226
|
|
|
223
227
|
options = normalize_provider_runtime(options)
|
|
228
|
+
skill_context = resolve_skills(options)
|
|
229
|
+
prompt = apply_skills_to_prompt(prompt, skill_context)
|
|
230
|
+
options = skill_context[:options]
|
|
224
231
|
|
|
225
232
|
extension_context = apply_extensions_to_prompt(prompt, options)
|
|
226
233
|
prompt = extension_context.prompt
|
|
@@ -236,7 +243,8 @@ module AgentHarness
|
|
|
236
243
|
env: build_env(options),
|
|
237
244
|
preparation: build_execution_preparation(options)
|
|
238
245
|
}
|
|
239
|
-
rescue ExtensionCompatibilityError, McpConfigurationError, McpUnsupportedError,
|
|
246
|
+
rescue ExtensionCompatibilityError, ConfigurationError, McpConfigurationError, McpUnsupportedError,
|
|
247
|
+
McpTransportUnsupportedError
|
|
240
248
|
raise
|
|
241
249
|
rescue => e
|
|
242
250
|
handle_error(e, prompt: prompt, options: options)
|
|
@@ -276,20 +284,30 @@ module AgentHarness
|
|
|
276
284
|
end
|
|
277
285
|
|
|
278
286
|
options = normalize_provider_runtime(options)
|
|
287
|
+
skill_context = resolve_skills(options, mode: :chat)
|
|
288
|
+
options = skill_context[:options]
|
|
279
289
|
options = normalize_sub_agent(options)
|
|
280
290
|
runtime = options[:provider_runtime]
|
|
281
291
|
conversation ||= messages
|
|
282
292
|
raise ArgumentError, "conversation or messages is required" unless conversation
|
|
283
|
-
|
|
293
|
+
use_runtime_chat_tools = tools.nil?
|
|
294
|
+
tools = runtime.chat_tools if use_runtime_chat_tools && runtime&.chat_tools
|
|
284
295
|
|
|
285
296
|
transport = resolve_chat_transport(options)
|
|
286
297
|
messages = format_messages_for_transport(conversation, transport)
|
|
298
|
+
messages = apply_skills_to_messages(messages, skill_context)
|
|
299
|
+
skill_tools = if use_runtime_chat_tools
|
|
300
|
+
skill_context[:tools]
|
|
301
|
+
else
|
|
302
|
+
merge_skill_chat_tools(skill_context[:tools], skill_context[:runtime_tools])
|
|
303
|
+
end
|
|
304
|
+
tools = merge_skill_chat_tools(tools, skill_tools)
|
|
287
305
|
extension_context = apply_extensions_to_chat(messages, tools, options)
|
|
288
306
|
messages = extension_context.messages
|
|
289
307
|
tools = extension_context.tools
|
|
290
308
|
options = extension_context.options
|
|
291
309
|
messages = apply_sub_agent_to_messages(messages, options[:translated_sub_agent])
|
|
292
|
-
validate_chat_mcp_servers!(options
|
|
310
|
+
validate_chat_mcp_servers!(options)
|
|
293
311
|
transport_opts = chat_transport_options(runtime, options)
|
|
294
312
|
transport_opts[:on_chat_chunk] = on_chat_chunk if on_chat_chunk
|
|
295
313
|
transport_opts[:observer] = observer if observer
|
|
@@ -308,7 +326,7 @@ module AgentHarness
|
|
|
308
326
|
log_debug("send_chat_message_complete", duration: response.duration, tokens: response.tokens)
|
|
309
327
|
|
|
310
328
|
response
|
|
311
|
-
rescue ExtensionCompatibilityError, ProviderError, AuthenticationError, RateLimitError, TimeoutError
|
|
329
|
+
rescue ExtensionCompatibilityError, ConfigurationError, ProviderError, AuthenticationError, RateLimitError, TimeoutError
|
|
312
330
|
raise
|
|
313
331
|
rescue => e
|
|
314
332
|
last_msg = conversation&.last || messages&.last
|
|
@@ -525,22 +543,49 @@ module AgentHarness
|
|
|
525
543
|
options.merge(provider_runtime: ProviderRuntime.wrap(raw))
|
|
526
544
|
end
|
|
527
545
|
|
|
546
|
+
def resolve_skills(options, mode: :message)
|
|
547
|
+
skill_refs = options[:skills]
|
|
548
|
+
cwd = options.fetch(:cwd, Dir.pwd)
|
|
549
|
+
home = options.fetch(:home, Dir.home)
|
|
550
|
+
skills = Skills.resolve_all(skill_refs, cwd: cwd, home: home)
|
|
551
|
+
return {skills: [], options: options, instructions: nil, tools: [], runtime_tools: []} if skills.empty?
|
|
552
|
+
|
|
553
|
+
skill_runtime = skills.map { |skill| ProviderRuntime.wrap(skill.provider_override_for(self.class.provider_name)) }
|
|
554
|
+
.compact
|
|
555
|
+
.reduce(nil) { |merged, sr| merged ? merged.merge(sr) : sr }
|
|
556
|
+
|
|
557
|
+
runtime = skill_runtime&.merge(options[:provider_runtime]) || options[:provider_runtime]
|
|
558
|
+
merged_options = options.merge(provider_runtime: runtime)
|
|
559
|
+
merged_options = merge_skill_message_tools(merged_options, skills) if mode == :message
|
|
560
|
+
merged_options = merge_skill_mcp_servers(merged_options, skills)
|
|
561
|
+
|
|
562
|
+
{
|
|
563
|
+
skills: skills,
|
|
564
|
+
options: merged_options,
|
|
565
|
+
instructions: skills.map(&:instructions).join("\n\n"),
|
|
566
|
+
tools: resolve_skill_chat_tools(skills),
|
|
567
|
+
runtime_tools: Array(skill_runtime&.chat_tools)
|
|
568
|
+
}
|
|
569
|
+
end
|
|
570
|
+
|
|
528
571
|
def normalize_mcp_servers(options)
|
|
529
|
-
if options.key?(:mcp_servers)
|
|
530
|
-
|
|
572
|
+
base_servers = if options.key?(:mcp_servers)
|
|
573
|
+
options[:mcp_servers]
|
|
531
574
|
else
|
|
532
575
|
# Configuration stores mcp_servers as a Hash keyed by name; extract values.
|
|
533
576
|
config_servers = @configuration.mcp_servers
|
|
534
|
-
|
|
577
|
+
config_servers.is_a?(Hash) ? config_servers.values : config_servers
|
|
535
578
|
end
|
|
536
|
-
|
|
579
|
+
skill_servers = Array(options[:skill_mcp_servers])
|
|
580
|
+
return options.except(:skill_mcp_servers) if base_servers.nil? && skill_servers.empty?
|
|
537
581
|
|
|
538
|
-
unless
|
|
582
|
+
unless base_servers.nil? || base_servers.is_a?(Array)
|
|
539
583
|
raise McpConfigurationError,
|
|
540
|
-
"mcp_servers must be an Array of Hash or McpServer, got #{
|
|
584
|
+
"mcp_servers must be an Array of Hash or McpServer, got #{base_servers.class}"
|
|
541
585
|
end
|
|
542
586
|
|
|
543
|
-
|
|
587
|
+
servers = Array(base_servers) + skill_servers
|
|
588
|
+
return options.except(:skill_mcp_servers) if servers.empty?
|
|
544
589
|
|
|
545
590
|
normalized = servers.map do |server|
|
|
546
591
|
if server.is_a?(McpServer)
|
|
@@ -560,7 +605,7 @@ module AgentHarness
|
|
|
560
605
|
"Duplicate MCP server names detected: #{duplicate_names.join(", ")}"
|
|
561
606
|
end
|
|
562
607
|
|
|
563
|
-
options.merge(mcp_servers: normalized)
|
|
608
|
+
options.except(:skill_mcp_servers).merge(mcp_servers: normalized)
|
|
564
609
|
end
|
|
565
610
|
|
|
566
611
|
def normalize_sub_agent(options)
|
|
@@ -584,6 +629,28 @@ module AgentHarness
|
|
|
584
629
|
[translated_sub_agent[:runtime_instructions], "User task:\n#{prompt}"].join("\n\n")
|
|
585
630
|
end
|
|
586
631
|
|
|
632
|
+
def apply_skills_to_prompt(prompt, skill_context)
|
|
633
|
+
instructions = skill_context[:instructions]
|
|
634
|
+
return prompt if instructions.nil? || instructions.empty?
|
|
635
|
+
|
|
636
|
+
[instructions, prompt].join("\n\n")
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def apply_skills_to_messages(messages, skill_context)
|
|
640
|
+
instructions = skill_context[:instructions]
|
|
641
|
+
return messages if instructions.nil? || instructions.empty?
|
|
642
|
+
|
|
643
|
+
# Prepend skill instructions to the first system message if one exists,
|
|
644
|
+
# rather than inserting a separate system turn that could be overridden.
|
|
645
|
+
if messages.any? && messages.first[:role] == "system"
|
|
646
|
+
merged = messages.dup
|
|
647
|
+
merged[0] = merged[0].merge(content: prepend_text_to_message_content(merged[0][:content], instructions))
|
|
648
|
+
merged
|
|
649
|
+
else
|
|
650
|
+
[{role: "system", content: instructions}] + messages
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
|
|
587
654
|
def apply_sub_agent_to_messages(messages, translated_sub_agent)
|
|
588
655
|
return messages unless translated_sub_agent
|
|
589
656
|
|
|
@@ -659,8 +726,12 @@ module AgentHarness
|
|
|
659
726
|
end
|
|
660
727
|
end
|
|
661
728
|
|
|
662
|
-
def validate_chat_mcp_servers!(
|
|
663
|
-
|
|
729
|
+
def validate_chat_mcp_servers!(options)
|
|
730
|
+
# Chat mode only needs to reject MCP servers introduced by the
|
|
731
|
+
# current request. Global configuration may still define default MCP
|
|
732
|
+
# servers for non-chat flows and should not make every chat call fail.
|
|
733
|
+
mcp_servers = Array(options[:mcp_servers]) + Array(options[:skill_mcp_servers])
|
|
734
|
+
return if mcp_servers.empty?
|
|
664
735
|
|
|
665
736
|
# Chat transports do not support request-scoped MCP servers.
|
|
666
737
|
# Raise early so extensions with MCP requirements are not silently ignored.
|
|
@@ -717,6 +788,149 @@ module AgentHarness
|
|
|
717
788
|
merged
|
|
718
789
|
end
|
|
719
790
|
|
|
791
|
+
def merge_skill_message_tools(options, skills)
|
|
792
|
+
return options if skills.empty?
|
|
793
|
+
return options if options[:tools] == :none
|
|
794
|
+
|
|
795
|
+
skill_tools = skills.flat_map { |skill| skill.tools.map { |tool| resolve_skill_message_tool(tool) } }.compact
|
|
796
|
+
return options if skill_tools.empty?
|
|
797
|
+
|
|
798
|
+
unless supports_message_tool_injection?
|
|
799
|
+
tool_names = skill_tools.map { |t| t.is_a?(Hash) ? extract_tool_name(t) : t }.compact
|
|
800
|
+
skill_names = skills.select { |s| s.tools.any? }.map(&:name)
|
|
801
|
+
raise ConfigurationError,
|
|
802
|
+
"Skills #{skill_names.join(", ")} define message-mode tools (#{tool_names.join(", ")}) " \
|
|
803
|
+
"but provider #{self.class.provider_name} does not support message-mode tool injection"
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
current_tools = options[:tools]
|
|
807
|
+
merged_tools = Array(current_tools) + skill_tools
|
|
808
|
+
duplicates = merged_tools.group_by(&:itself).select { |_, entries| entries.size > 1 }.keys
|
|
809
|
+
unless duplicates.empty?
|
|
810
|
+
raise ConfigurationError, "Tool name conflict between explicit and skill tools: #{duplicates.join(", ")}"
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
options.merge(tools: merged_tools)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def merge_skill_mcp_servers(options, skills)
|
|
817
|
+
skill_servers = skills.flat_map do |skill|
|
|
818
|
+
skill.mcp_servers.map { |server| [skill.name, server] }
|
|
819
|
+
end
|
|
820
|
+
return options if skill_servers.empty?
|
|
821
|
+
|
|
822
|
+
existing_servers = normalized_mcp_server_sources(options)
|
|
823
|
+
duplicates = duplicate_skill_mcp_server_names(existing_servers, skill_servers)
|
|
824
|
+
unless duplicates.empty?
|
|
825
|
+
conflicts = duplicates.map do |name, owners|
|
|
826
|
+
formatted_owners = owners.map { |owner| (owner == :explicit) ? "explicit" : "skill: #{owner}" }.join(", ")
|
|
827
|
+
"#{name} (#{formatted_owners})"
|
|
828
|
+
end
|
|
829
|
+
raise ConfigurationError,
|
|
830
|
+
"MCP server name conflict across explicit and skill servers: #{conflicts.join(", ")}"
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
options.merge(skill_mcp_servers: deep_dup(skill_servers.map(&:last)))
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def resolve_skill_chat_tools(skills)
|
|
837
|
+
skills.flat_map do |skill|
|
|
838
|
+
skill.tools.map { |tool| normalize_skill_chat_tool_for_provider(resolve_skill_tool_mapping(tool)) }
|
|
839
|
+
end
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
def merge_skill_chat_tools(tools, skill_tools)
|
|
843
|
+
return tools if skill_tools.empty?
|
|
844
|
+
|
|
845
|
+
merged = Array(tools) + skill_tools
|
|
846
|
+
names = merged.map { |tool| extract_tool_name(tool) }.compact
|
|
847
|
+
duplicates = names.group_by { |name| name }.select { |_, entries| entries.size > 1 }.keys
|
|
848
|
+
unless duplicates.empty?
|
|
849
|
+
raise ConfigurationError, "Tool name conflict between explicit and skill tools: #{duplicates.join(", ")}"
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
merged
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def resolve_skill_message_tool(tool)
|
|
856
|
+
resolved = resolve_skill_tool_mapping(tool)
|
|
857
|
+
return resolved if resolved.is_a?(String)
|
|
858
|
+
return extract_tool_name(resolved) if resolved.is_a?(Hash)
|
|
859
|
+
|
|
860
|
+
raise ConfigurationError, "Unsupported skill tool mapping for message mode: #{resolved.inspect}"
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
def normalize_skill_chat_tool_for_provider(tool)
|
|
864
|
+
normalized_tool = tool.is_a?(Hash) ? tool : {name: tool.to_s}
|
|
865
|
+
normalize_extension_tool_for_provider(normalized_tool)
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
def duplicate_skill_mcp_server_names(existing_servers, skill_servers)
|
|
869
|
+
owners_by_name = Hash.new { |hash, key| hash[key] = [] }
|
|
870
|
+
existing_servers.each do |server|
|
|
871
|
+
name = server_name(server)
|
|
872
|
+
owners_by_name[name] << :explicit if name
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
skill_servers.each do |(skill_name, server)|
|
|
876
|
+
name = server_name(server)
|
|
877
|
+
owners_by_name[name] << skill_name if name
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
owners_by_name.each_with_object({}) do |(name, owners), duplicates|
|
|
881
|
+
duplicates[name] = owners if owners.uniq.size > 1
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def normalized_mcp_server_sources(options)
|
|
886
|
+
if options.key?(:mcp_servers)
|
|
887
|
+
Array(options[:mcp_servers])
|
|
888
|
+
else
|
|
889
|
+
config_servers = @configuration.mcp_servers
|
|
890
|
+
config_servers = config_servers.values if config_servers.is_a?(Hash)
|
|
891
|
+
Array(config_servers)
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def server_name(server)
|
|
896
|
+
if server.is_a?(McpServer)
|
|
897
|
+
server.name
|
|
898
|
+
else
|
|
899
|
+
server[:name] || server["name"]
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
def resolve_skill_tool_mapping(tool)
|
|
904
|
+
case tool
|
|
905
|
+
when Symbol, String
|
|
906
|
+
if @configuration.tool_registry.registered?(tool)
|
|
907
|
+
mapping = @configuration.tool_registry.fetch(tool).mapping_for(self.class.provider_name)
|
|
908
|
+
mapping.nil? ? tool.to_s : mapping
|
|
909
|
+
else
|
|
910
|
+
tool.to_s
|
|
911
|
+
end
|
|
912
|
+
when Hash
|
|
913
|
+
deep_dup(tool)
|
|
914
|
+
else
|
|
915
|
+
raise ConfigurationError, "Unsupported tool reference #{tool.inspect} in skill definition"
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
def prepend_text_to_message_content(content, text)
|
|
920
|
+
case content
|
|
921
|
+
when nil
|
|
922
|
+
text
|
|
923
|
+
when String
|
|
924
|
+
"#{text}\n\n#{content}"
|
|
925
|
+
when Array
|
|
926
|
+
[{type: "text", text: "#{text}\n\n"}] + deep_dup(content)
|
|
927
|
+
when Hash
|
|
928
|
+
[{type: "text", text: "#{text}\n\n"}, deep_dup(content)]
|
|
929
|
+
else
|
|
930
|
+
raise ConfigurationError, "Unsupported system message content type for skill instructions: #{content.class}"
|
|
931
|
+
end
|
|
932
|
+
end
|
|
933
|
+
|
|
720
934
|
def extract_tool_name(tool)
|
|
721
935
|
tool[:name] || tool["name"] ||
|
|
722
936
|
tool.dig(:function, :name) || tool.dig(:function, "name") ||
|
|
@@ -255,6 +255,9 @@ module AgentHarness
|
|
|
255
255
|
|
|
256
256
|
# Coerce provider_runtime from Hash if needed (same as Base#send_message)
|
|
257
257
|
options = normalize_provider_runtime(options)
|
|
258
|
+
skill_context = resolve_skills(options)
|
|
259
|
+
prompt = apply_skills_to_prompt(prompt, skill_context)
|
|
260
|
+
options = skill_context[:options]
|
|
258
261
|
runtime = options[:provider_runtime]
|
|
259
262
|
|
|
260
263
|
# Normalize and validate MCP servers (same as Base#send_message)
|
|
@@ -306,7 +309,7 @@ module AgentHarness
|
|
|
306
309
|
log_debug("send_message_complete", duration: duration)
|
|
307
310
|
|
|
308
311
|
response
|
|
309
|
-
rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
312
|
+
rescue ConfigurationError, McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
310
313
|
raise
|
|
311
314
|
rescue => e
|
|
312
315
|
handle_error(e, prompt: prompt, options: options)
|
|
@@ -316,6 +319,9 @@ module AgentHarness
|
|
|
316
319
|
log_debug("plan_execution_start", prompt_length: prompt.length, options: options.keys)
|
|
317
320
|
|
|
318
321
|
options = normalize_provider_runtime(options)
|
|
322
|
+
skill_context = resolve_skills(options)
|
|
323
|
+
prompt = apply_skills_to_prompt(prompt, skill_context)
|
|
324
|
+
options = skill_context[:options]
|
|
319
325
|
options = normalize_mcp_servers(options)
|
|
320
326
|
validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
|
|
321
327
|
|
|
@@ -328,7 +334,7 @@ module AgentHarness
|
|
|
328
334
|
env: build_env(options),
|
|
329
335
|
preparation: build_execution_preparation(options)
|
|
330
336
|
}
|
|
331
|
-
rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
337
|
+
rescue ConfigurationError, McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
332
338
|
raise
|
|
333
339
|
rescue => e
|
|
334
340
|
handle_error(e, prompt: prompt, options: options)
|
|
@@ -292,6 +292,9 @@ module AgentHarness
|
|
|
292
292
|
log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
|
|
293
293
|
|
|
294
294
|
options = normalize_provider_runtime(options)
|
|
295
|
+
skill_context = resolve_skills(options)
|
|
296
|
+
prompt = apply_skills_to_prompt(prompt, skill_context)
|
|
297
|
+
options = skill_context[:options]
|
|
295
298
|
options = normalize_mcp_servers(options)
|
|
296
299
|
validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
|
|
297
300
|
|
|
@@ -339,7 +342,7 @@ module AgentHarness
|
|
|
339
342
|
log_debug("send_message_complete", duration: duration, tokens: response.tokens)
|
|
340
343
|
|
|
341
344
|
response
|
|
342
|
-
rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
345
|
+
rescue ConfigurationError, McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
343
346
|
raise
|
|
344
347
|
rescue => e
|
|
345
348
|
handle_error(e, prompt: prompt, options: options)
|
|
@@ -349,6 +352,9 @@ module AgentHarness
|
|
|
349
352
|
log_debug("plan_execution_start", prompt_length: prompt.length, options: options.keys)
|
|
350
353
|
|
|
351
354
|
options = normalize_provider_runtime(options)
|
|
355
|
+
skill_context = resolve_skills(options)
|
|
356
|
+
prompt = apply_skills_to_prompt(prompt, skill_context)
|
|
357
|
+
options = skill_context[:options]
|
|
352
358
|
options = normalize_mcp_servers(options)
|
|
353
359
|
validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
|
|
354
360
|
|
|
@@ -363,7 +369,7 @@ module AgentHarness
|
|
|
363
369
|
env: env,
|
|
364
370
|
preparation: build_execution_preparation(options)
|
|
365
371
|
}
|
|
366
|
-
rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
372
|
+
rescue ConfigurationError, McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
|
|
367
373
|
raise
|
|
368
374
|
rescue => e
|
|
369
375
|
handle_error(e, prompt: prompt, options: options)
|
|
@@ -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.17.
|
|
4
|
+
version: 0.17.3
|
|
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
|