agent-harness 0.17.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68dcb8a37c35c6ded8f4d01e0f0d6d14e1588ac41a88f2b613fa2fbab9decc79
4
- data.tar.gz: 6317ce0f0ef2aaadf13dfa39b6897d67a4b453bbb7b7b09db348366d33b8d993
3
+ metadata.gz: 5eee3cd4f4e77fe648ed70a6c31c29af4959b90d7fb848534ecdf75558811d0f
4
+ data.tar.gz: 8eca8a991857a8518bc9b192d78984bb459470725da9727ac830fd42eaf2a91b
5
5
  SHA512:
6
- metadata.gz: 59989e7197cf596e87ae4ffb87f252419d72f3c9c119de4f210fc4a41c7b3777187a69f1745e65f7a1cefb0010b87f316cd375c673de56e906e4b223364dac44
7
- data.tar.gz: a691c948ee1fb9f3f6777b3624863b42024ecf85c84b5b320a0e258cf4f7467db7832e703048cee42272cd3e946c020321873788d48de225acaf00eaa8bee757
6
+ metadata.gz: b3d2b6cd29f4b9607b52b935d323db5217514c3e32b213f222c3fd2b4731e2407315854bdbbdd2e4857c84302b4ae21e844e611bff11e24f051a28f035da93db
7
+ data.tar.gz: 979df7fc6616509e14593d0fee2f189ec4a5d7b3c34d071fe755fab31115bf932f90484f3e1483dbabad956383c8bc1528174ec08791bc32a186d814295d87ef
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.17.1"
2
+ ".": "0.17.3"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
10
+ ## [0.17.2](https://github.com/viamin/agent-harness/compare/agent-harness/v0.17.1...agent-harness/v0.17.2) (2026-05-05)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * 160: Add support for the pi agent CLI ([#203](https://github.com/viamin/agent-harness/issues/203)) ([0aeb607](https://github.com/viamin/agent-harness/commit/0aeb607ea98b52ba8202726dc946b8c1db09a3cd))
16
+
3
17
  ## [0.17.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.17.0...agent-harness/v0.17.1) (2026-05-05)
4
18
 
5
19
 
data/README.md CHANGED
@@ -5,7 +5,7 @@ A unified Ruby interface for CLI-based AI coding agents like Claude Code, Cursor
5
5
  ## Features
6
6
 
7
7
  - **Unified Interface**: Single API for multiple AI coding agents
8
- - **9 Built-in Providers**: Claude Code, Cursor, Gemini CLI, GitHub Copilot, Codex, Aider, OpenCode, Kilocode, Mistral Vibe
8
+ - **10 Built-in Providers**: Claude Code, Cursor, Gemini CLI, GitHub Copilot, Codex, Pi, Aider, OpenCode, Kilocode, Mistral Vibe
9
9
  - **Full Orchestration**: Provider switching, circuit breakers, rate limiting, and health monitoring
10
10
  - **Flexible Configuration**: YAML, Ruby DSL, or environment variables
11
11
  - **Token Tracking**: Monitor usage across providers for cost and limit management
@@ -104,6 +104,7 @@ end
104
104
  | `:gemini` | `gemini` | Google Gemini CLI |
105
105
  | `:github_copilot` | `copilot` | GitHub Copilot CLI |
106
106
  | `:codex` | `codex` | OpenAI Codex CLI |
107
+ | `:pi` | `pi` | Pi coding agent CLI |
107
108
  | `:aider` | `aider` | Aider coding assistant |
108
109
  | `:opencode` | `opencode` | OpenCode CLI |
109
110
  | `:kilocode` | `kilo` | Kilocode CLI |
@@ -187,7 +188,7 @@ puts contract[:supported_versions][:requirement]
187
188
 
188
189
  # List all registered providers
189
190
  AgentHarness::Providers::Registry.instance.all
190
- # => [:claude, :cursor, :gemini, :github_copilot, :codex, :opencode, :kilocode, :aider, :mistral_vibe]
191
+ # => [:claude, :cursor, :gemini, :github_copilot, :pi, :codex, :opencode, :kilocode, :aider, :mistral_vibe]
191
192
  ```
192
193
 
193
194
  For Claude, the install contract is the first-class source of truth for:
@@ -377,7 +377,7 @@ module AgentHarness
377
377
  # Provider-specific configuration
378
378
  class ProviderConfig
379
379
  attr_accessor :enabled, :type, :priority, :models, :default_flags, :timeout, :model,
380
- :externally_sandboxed
380
+ :provider, :externally_sandboxed
381
381
 
382
382
  attr_reader :name
383
383
 
@@ -390,6 +390,7 @@ module AgentHarness
390
390
  @default_flags = []
391
391
  @timeout = nil
392
392
  @model = nil
393
+ @provider = nil
393
394
  @externally_sandboxed = false
394
395
  end
395
396
 
@@ -419,7 +420,10 @@ module AgentHarness
419
420
  end
420
421
 
421
422
  def mapping_for(provider)
422
- deep_dup(@provider_mappings[provider.to_sym])
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)
423
427
  end
424
428
 
425
429
  def to_h
@@ -428,6 +432,15 @@ module AgentHarness
428
432
 
429
433
  private
430
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
+
431
444
  def deep_dup(value)
432
445
  case value
433
446
  when Array
@@ -46,9 +46,9 @@ module AgentHarness
46
46
  validate_optional_string!(:base_url, base_url)
47
47
  validate_optional_string!(:api_provider, api_provider)
48
48
 
49
- @model = model
50
- @base_url = base_url
51
- @api_provider = api_provider
49
+ @model = normalize_optional_string(model)
50
+ @base_url = normalize_optional_string(base_url)
51
+ @api_provider = normalize_optional_string(api_provider)
52
52
 
53
53
  env_hash = env.nil? ? {} : env
54
54
  unless env_hash.is_a?(Hash)
@@ -113,9 +113,9 @@ module AgentHarness
113
113
  "chat_tools must be an Array of Hashes; invalid element at index #{index}: #{tool.inspect} (#{tool.class})"
114
114
  end
115
115
  end
116
- @chat_base_url = chat_base_url
117
- @chat_model = chat_model
118
- @chat_api_key = chat_api_key
116
+ @chat_base_url = normalize_optional_string(chat_base_url)
117
+ @chat_model = normalize_optional_string(chat_model)
118
+ @chat_api_key = normalize_optional_string(chat_api_key)
119
119
  @chat_max_tokens = chat_max_tokens
120
120
  @chat_tools = normalized_chat_tools&.freeze
121
121
 
@@ -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,11 +222,39 @@ 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)
191
249
 
192
250
  raise ArgumentError, "#{name} must be a String or nil (got #{value.class})"
193
251
  end
252
+
253
+ def normalize_optional_string(value)
254
+ return value unless value.respond_to?(:strip)
255
+
256
+ normalized = value.strip
257
+ normalized.empty? ? nil : normalized
258
+ end
194
259
  end
195
260
  end
@@ -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
- return send_text_message(prompt, **options.except(:mode))
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, McpTransportUnsupportedError
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, McpTransportUnsupportedError
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
- tools = runtime.chat_tools if tools.nil? && runtime&.chat_tools
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[:mcp_servers])
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
- servers = options[:mcp_servers]
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
- servers = config_servers.is_a?(Hash) ? config_servers.values : config_servers
577
+ config_servers.is_a?(Hash) ? config_servers.values : config_servers
535
578
  end
536
- return options if servers.nil?
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 servers.is_a?(Array)
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 #{servers.class}"
584
+ "mcp_servers must be an Array of Hash or McpServer, got #{base_servers.class}"
541
585
  end
542
586
 
543
- return options if servers.empty?
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!(mcp_servers)
663
- return if mcp_servers.nil? || mcp_servers.empty?
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 "rubygems/requirement"
4
+
5
+ module AgentHarness
6
+ module Providers
7
+ # Pi coding agent CLI provider
8
+ #
9
+ # Provides integration with the Pi terminal coding agent.
10
+ class Pi < Base
11
+ CLI_PACKAGE = "@mariozechner/pi-coding-agent"
12
+ SUPPORTED_CLI_VERSION = "0.73.0"
13
+ SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new("= #{SUPPORTED_CLI_VERSION}").freeze
14
+
15
+ class << self
16
+ def provider_name
17
+ :pi
18
+ end
19
+
20
+ def binary_name
21
+ "pi"
22
+ end
23
+
24
+ def available?
25
+ executor = AgentHarness.configuration.command_executor
26
+ !!executor.which(binary_name)
27
+ end
28
+
29
+ def provider_metadata_overrides
30
+ {
31
+ auth: {
32
+ service: :pi,
33
+ api_family: :multi_provider
34
+ }
35
+ }
36
+ end
37
+
38
+ def firewall_requirements
39
+ {
40
+ domains: [
41
+ "pi.dev"
42
+ ],
43
+ ip_ranges: []
44
+ }
45
+ end
46
+
47
+ def instruction_file_paths
48
+ [
49
+ {
50
+ path: "AGENTS.md",
51
+ description: "Pi agent instructions",
52
+ symlink: false
53
+ },
54
+ {
55
+ path: "SYSTEM.md",
56
+ description: "Pi system prompt override",
57
+ symlink: false
58
+ }
59
+ ]
60
+ end
61
+
62
+ def discover_models
63
+ return [] unless available?
64
+ []
65
+ end
66
+
67
+ def installation_contract(version: SUPPORTED_CLI_VERSION)
68
+ version = version.strip if version.respond_to?(:strip)
69
+
70
+ unless version.is_a?(String) && !version.empty?
71
+ raise ArgumentError,
72
+ "Unsupported Pi CLI version #{version.inspect}; " \
73
+ "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
74
+ end
75
+
76
+ parsed_version = begin
77
+ Gem::Version.new(version)
78
+ rescue ArgumentError
79
+ raise ArgumentError,
80
+ "Unsupported Pi CLI version #{version.inspect}; " \
81
+ "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
82
+ end
83
+
84
+ unless SUPPORTED_CLI_REQUIREMENT.satisfied_by?(parsed_version)
85
+ raise ArgumentError,
86
+ "Unsupported Pi CLI version #{version.inspect}; " \
87
+ "supported versions must satisfy #{SUPPORTED_CLI_REQUIREMENT}"
88
+ end
89
+
90
+ package = "#{CLI_PACKAGE}@#{version}".freeze
91
+ install_command_prefix = ["npm", "install", "-g", "--ignore-scripts"].freeze
92
+ install_command = (install_command_prefix + [package]).freeze
93
+ supported_versions = [version].freeze
94
+ version_requirement = SUPPORTED_CLI_REQUIREMENT.requirements
95
+ .map { |op, ver| "#{op} #{ver}".freeze }
96
+ .freeze
97
+
98
+ contract = {
99
+ source: :npm,
100
+ package: package,
101
+ package_name: CLI_PACKAGE,
102
+ version: version,
103
+ version_requirement: version_requirement,
104
+ binary_name: binary_name,
105
+ install_command_prefix: install_command_prefix,
106
+ install_command: install_command,
107
+ supported_versions: supported_versions
108
+ }
109
+
110
+ contract.each_value do |value|
111
+ value.freeze if value.is_a?(String)
112
+ end
113
+ contract.freeze
114
+ end
115
+
116
+ def smoke_test_contract
117
+ Base::DEFAULT_SMOKE_TEST_CONTRACT
118
+ end
119
+ end
120
+
121
+ def name
122
+ "pi"
123
+ end
124
+
125
+ def display_name
126
+ "Pi Coding Agent"
127
+ end
128
+
129
+ def configuration_schema
130
+ {
131
+ fields: [
132
+ {
133
+ name: :model,
134
+ type: :string,
135
+ label: "Model",
136
+ required: false,
137
+ hint: "Pi model pattern or ID passed to --model"
138
+ },
139
+ {
140
+ name: :provider,
141
+ type: :string,
142
+ label: "Provider",
143
+ required: false,
144
+ hint: "Pi provider name passed to --provider"
145
+ }
146
+ ],
147
+ auth_modes: %i[api_key oauth],
148
+ openai_compatible: false
149
+ }
150
+ end
151
+
152
+ def capabilities
153
+ {
154
+ streaming: false,
155
+ file_upload: true,
156
+ vision: true,
157
+ tool_use: true,
158
+ # Pi's non-interactive CLI currently exposes only text print mode.
159
+ # Keep JSON mode disabled until the CLI ships a structured output flag.
160
+ json_mode: false,
161
+ mcp: false,
162
+ dangerous_mode: false
163
+ }
164
+ end
165
+
166
+ def error_patterns
167
+ COMMON_ERROR_PATTERNS
168
+ end
169
+
170
+ def supports_tool_control?
171
+ true
172
+ end
173
+
174
+ def auth_type
175
+ :oauth
176
+ end
177
+
178
+ def execution_semantics
179
+ {
180
+ prompt_delivery: :flag,
181
+ output_format: :text,
182
+ sandbox_aware: false,
183
+ uses_subcommand: false,
184
+ non_interactive_flag: "-p",
185
+ legitimate_exit_codes: [0],
186
+ stderr_is_diagnostic: true,
187
+ parses_rate_limit_reset: false
188
+ }
189
+ end
190
+
191
+ protected
192
+
193
+ def build_command(prompt, options)
194
+ runtime = options[:provider_runtime]
195
+ provider = runtime&.api_provider || @config.provider
196
+ model = runtime&.model || @config.model
197
+
198
+ cmd = [self.class.binary_name, "--no-session"]
199
+ cmd += @config.default_flags if @config.default_flags&.any?
200
+ cmd += runtime.flags if runtime
201
+ cmd += ["--provider", provider] if provider
202
+ cmd += ["--model", model] if model
203
+ cmd << "--no-tools" if options[:tools] == :none
204
+ cmd += ["-p", prompt]
205
+
206
+ cmd
207
+ end
208
+
209
+ def default_timeout
210
+ 300
211
+ end
212
+ end
213
+ end
214
+ end
@@ -27,6 +27,7 @@ module AgentHarness
27
27
  class_name: :GithubCopilot,
28
28
  aliases: [:copilot]
29
29
  },
30
+ {name: :pi, require_path: "agent_harness/providers/pi", class_name: :Pi, aliases: []},
30
31
  {name: :codex, require_path: "agent_harness/providers/codex", class_name: :Codex, aliases: []},
31
32
  {name: :opencode, require_path: "agent_harness/providers/opencode", class_name: :Opencode, aliases: []},
32
33
  {name: :kilocode, require_path: "agent_harness/providers/kilocode", class_name: :Kilocode, aliases: []},
@@ -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.1"
4
+ VERSION = "0.17.3"
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.1
4
+ version: 0.17.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -133,10 +133,13 @@ files:
133
133
  - lib/agent_harness/providers/mcp_config_file_support.rb
134
134
  - lib/agent_harness/providers/mistral_vibe.rb
135
135
  - lib/agent_harness/providers/opencode.rb
136
+ - lib/agent_harness/providers/pi.rb
136
137
  - lib/agent_harness/providers/rate_limit_reset_parsing.rb
137
138
  - lib/agent_harness/providers/registry.rb
138
139
  - lib/agent_harness/providers/token_usage_parsing.rb
139
140
  - lib/agent_harness/response.rb
141
+ - lib/agent_harness/skill.rb
142
+ - lib/agent_harness/skills.rb
140
143
  - lib/agent_harness/sub_agent_config.rb
141
144
  - lib/agent_harness/sub_agent_file_loader.rb
142
145
  - lib/agent_harness/sub_agent_translator.rb