agent-harness 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50b5bc213cf4a1b6f9441a06a82c38210c7e6907eb085e6cadf71e305e4b7897
4
- data.tar.gz: 9785ea0b1b35f5aa52528741ce070287f2d433235ffeaf3d6df0ab7337f486b0
3
+ metadata.gz: 9e1a243bc20db360b93c22248fd0b35864a995dd6a78def3fdca291d5e39809d
4
+ data.tar.gz: 591bc4baf70eeb598b4c7a6736b4c0090b7108c6f34628b75a1b5b68eb991805
5
5
  SHA512:
6
- metadata.gz: 2343d812d85375faad3a55e0462eb228003ff5eddc1920fd1edb75aedf381e5b86e3062c191ee64d24c39f245c1f27ded7d0e74247526727901b6b5391c2518a
7
- data.tar.gz: cfe73ff0ffce00f5e0be1726b3dc28d0cdb73aab6546988c19b79846c1d16014476302f2d258d13bb703124edbf86066a96cd43044047ebacc81ad0cf5c853e5
6
+ metadata.gz: 1028da8c56be8d3f7e948dd23bfd25fbfae3515d1f678fe5c9b5172f5f5a82dbd0fad959ef94d2681619fdf44a6d509dcfc925bc20fb2d3e4e2c92f44ea19d9a
7
+ data.tar.gz: f8ed1ca7bccfa683eee9303a977920d177a62832b48d937b01c107407890ea60b17680030cf4bc8ca3cd3bdf495def3d90518da89943d55306239ee15869c0c2
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.11.2"
2
+ ".": "0.12.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.12.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.11.3...agent-harness/v0.12.0) (2026-05-01)
4
+
5
+
6
+ ### Features
7
+
8
+ * streaming JSONL event parser for real-time Codex progress tracking ([#184](https://github.com/viamin/agent-harness/issues/184)) ([4905539](https://github.com/viamin/agent-harness/commit/490553992904f39e52028b2140ab99755aad1fb1))
9
+
10
+ ## [0.11.3](https://github.com/viamin/agent-harness/compare/agent-harness/v0.11.2...agent-harness/v0.11.3) (2026-04-28)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * 164: Support provider-agnostic extensions across compatible providers ([#168](https://github.com/viamin/agent-harness/issues/168)) ([2880ae4](https://github.com/viamin/agent-harness/commit/2880ae4f150d1d5574f259b931bbee14ebe0ed04))
16
+
3
17
  ## [0.11.2](https://github.com/viamin/agent-harness/compare/agent-harness/v0.11.1...agent-harness/v0.11.2) (2026-04-27)
4
18
 
5
19
 
@@ -23,7 +23,7 @@ module AgentHarness
23
23
  attr_writer :command_executor
24
24
 
25
25
  attr_reader :providers, :orchestration_config, :callbacks, :custom_provider_classes
26
- attr_reader :sub_agents, :tool_registry, :mcp_servers
26
+ attr_reader :sub_agents, :tool_registry, :mcp_servers, :extension_registry
27
27
 
28
28
  def initialize
29
29
  @logger = nil # Will use null logger if not set
@@ -40,6 +40,7 @@ module AgentHarness
40
40
  @sub_agents = {}
41
41
  @tool_registry = ToolRegistry.new
42
42
  @mcp_servers = {}
43
+ @extension_registry = Extensions::Registry.new
43
44
  end
44
45
 
45
46
  # Get or lazily initialize the command executor
@@ -147,6 +148,56 @@ module AgentHarness
147
148
  @mcp_servers[name.to_sym] = server
148
149
  end
149
150
 
151
+ # Register a provider-agnostic runtime extension.
152
+ #
153
+ # @param extension [Extensions::Base] extension instance
154
+ # @param as [Symbol, String, nil] optional registry key override
155
+ # @return [Extensions::Base]
156
+ def register_extension(extension, as: nil)
157
+ @extension_registry.register(extension, as: as)
158
+ extension
159
+ end
160
+
161
+ # Load one or more extensions from disk through an adapter.
162
+ #
163
+ # @param path [String] extension file, directory, or package root
164
+ # @param adapter [Symbol, String, nil] optional explicit adapter name
165
+ # @return [Array<Extensions::Base>]
166
+ def load_extensions(path, adapter: nil)
167
+ Extensions::Loader.load(path, adapter: adapter).each do |extension|
168
+ register_extension(extension)
169
+ end
170
+ end
171
+
172
+ # Discover and register all extensions found in a directory.
173
+ #
174
+ # Each child entry (subdirectory or file) is loaded through the
175
+ # appropriate adapter; entries that cannot be parsed are silently
176
+ # skipped.
177
+ #
178
+ # @param directory [String] directory to scan
179
+ # @return [Array<Extensions::Base>]
180
+ def discover_extensions(directory)
181
+ Extensions::Loader.discover(directory).each do |extension|
182
+ register_extension(extension)
183
+ end
184
+ end
185
+
186
+ # Resolve a registered or inline extension reference.
187
+ #
188
+ # @param reference [Symbol, String, Extensions::Base, nil]
189
+ # @return [Extensions::Base, nil]
190
+ def resolve_extension(reference)
191
+ case reference
192
+ when nil
193
+ nil
194
+ when Extensions::Base
195
+ reference
196
+ else
197
+ @extension_registry.fetch(reference)
198
+ end
199
+ end
200
+
150
201
  # Resolve a named or inline sub-agent definition.
151
202
  #
152
203
  # @param reference [Symbol, String, Hash, SubAgentConfig, nil] sub-agent reference
@@ -72,6 +72,17 @@ module AgentHarness
72
72
  # Configuration errors
73
73
  class ConfigurationError < Error; end
74
74
 
75
+ class ExtensionCompatibilityError < ConfigurationError
76
+ attr_reader :provider, :extension, :report
77
+
78
+ def initialize(message = nil, provider: nil, extension: nil, report: nil, **kwargs)
79
+ @provider = provider
80
+ @extension = extension
81
+ @report = report
82
+ super(message, **kwargs)
83
+ end
84
+ end
85
+
75
86
  # MCP-specific errors
76
87
  class McpConfigurationError < ConfigurationError; end
77
88
 
@@ -0,0 +1,644 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AgentHarness
6
+ module Extensions
7
+ module DeepDupable
8
+ private
9
+
10
+ def deep_dup(value)
11
+ case value
12
+ when Array
13
+ value.map { |entry| deep_dup(entry) }
14
+ when Hash
15
+ value.each_with_object({}) { |(key, entry), copy| copy[key] = deep_dup(entry) }
16
+ else
17
+ value.dup
18
+ end
19
+ rescue TypeError
20
+ value
21
+ end
22
+ end
23
+
24
+ class Base
25
+ def name
26
+ self.class.name.split("::").last&.downcase&.to_sym
27
+ end
28
+
29
+ def description
30
+ nil
31
+ end
32
+
33
+ def version
34
+ nil
35
+ end
36
+
37
+ def on_message_before(context)
38
+ context
39
+ end
40
+
41
+ def on_message_after(context)
42
+ context
43
+ end
44
+
45
+ def on_tools_available(context)
46
+ context
47
+ end
48
+
49
+ def tools
50
+ []
51
+ end
52
+
53
+ def mcp_servers
54
+ []
55
+ end
56
+
57
+ def system_prompt_additions
58
+ []
59
+ end
60
+
61
+ def unsupported_features
62
+ []
63
+ end
64
+
65
+ def required_provider_capabilities
66
+ required = []
67
+ required << :tool_use if tools.any?
68
+ required << :mcp if mcp_servers.any?
69
+ required
70
+ end
71
+ end
72
+
73
+ class MessageContext
74
+ attr_accessor :prompt, :messages, :tools, :options, :response, :metadata
75
+ attr_reader :provider, :extensions, :mode
76
+
77
+ def initialize(provider:, extensions:, mode:, options:, prompt: nil, messages: nil, tools: nil, response: nil,
78
+ metadata: {})
79
+ @provider = provider
80
+ @extensions = extensions.freeze
81
+ @mode = mode
82
+ @options = options
83
+ @prompt = prompt
84
+ @messages = messages
85
+ @tools = tools
86
+ @response = response
87
+ @metadata = metadata
88
+ end
89
+ end
90
+
91
+ class CompatibilityReport
92
+ attr_reader :extension, :provider, :missing_provider_capabilities, :unsupported_features
93
+
94
+ def initialize(extension:, provider:, missing_provider_capabilities:, unsupported_features:)
95
+ @extension = extension
96
+ @provider = provider
97
+ @missing_provider_capabilities = missing_provider_capabilities.freeze
98
+ @unsupported_features = unsupported_features.freeze
99
+ end
100
+
101
+ def compatible?
102
+ @missing_provider_capabilities.empty?
103
+ end
104
+
105
+ def fully_supported?
106
+ compatible? && @unsupported_features.empty?
107
+ end
108
+
109
+ def to_h
110
+ {
111
+ extension: extension.name,
112
+ provider: provider.class.provider_name,
113
+ compatible: compatible?,
114
+ fully_supported: fully_supported?,
115
+ missing_provider_capabilities: missing_provider_capabilities.dup,
116
+ unsupported_features: unsupported_features.dup
117
+ }
118
+ end
119
+ end
120
+
121
+ module Compatibility
122
+ HARNESS_CAPABILITIES = {
123
+ message_hooks: true,
124
+ response_hooks: true,
125
+ system_prompt_additions: true
126
+ }.freeze
127
+
128
+ module_function
129
+
130
+ def report(provider:, extension:)
131
+ required = Array(extension.required_provider_capabilities).map(&:to_sym)
132
+ missing = required.reject { |capability| capability_supported?(provider, capability) }
133
+ unsupported = Array(extension.unsupported_features).map(&:to_sym)
134
+
135
+ CompatibilityReport.new(
136
+ extension: extension,
137
+ provider: provider,
138
+ missing_provider_capabilities: missing,
139
+ unsupported_features: unsupported
140
+ )
141
+ end
142
+
143
+ def check!(provider:, extension:, strict: true)
144
+ compatibility = report(provider: provider, extension: extension)
145
+ return compatibility if compatibility.compatible?
146
+ return compatibility unless strict
147
+
148
+ raise ExtensionCompatibilityError.new(
149
+ "Extension '#{extension.name}' is not compatible with provider '#{provider.class.provider_name}': " \
150
+ "missing provider capabilities: #{compatibility.missing_provider_capabilities.inspect}",
151
+ provider: provider.class.provider_name,
152
+ extension: extension.name,
153
+ report: compatibility.to_h
154
+ )
155
+ end
156
+
157
+ def capability_supported?(provider, capability)
158
+ return HARNESS_CAPABILITIES.fetch(capability) if HARNESS_CAPABILITIES.key?(capability)
159
+
160
+ case capability
161
+ when :chat
162
+ provider.supports_chat?
163
+ when :text_mode
164
+ provider.supports_text_mode?
165
+ else
166
+ !!provider.capabilities[capability]
167
+ end
168
+ end
169
+ end
170
+
171
+ module Composition
172
+ module_function
173
+
174
+ def compose(extensions)
175
+ return [] if extensions.nil? || extensions.empty?
176
+
177
+ detect_tool_conflicts(extensions)
178
+ extensions
179
+ end
180
+
181
+ def detect_tool_conflicts(extensions)
182
+ tool_owners = {}
183
+
184
+ extensions.each do |extension|
185
+ extension.tools.each do |tool|
186
+ tool_name = tool[:name] || tool["name"]
187
+ next unless tool_name
188
+
189
+ if tool_owners.key?(tool_name)
190
+ raise ConfigurationError,
191
+ "Tool name conflict: '#{tool_name}' is provided by both " \
192
+ "'#{tool_owners[tool_name]}' and '#{extension.name}'"
193
+ end
194
+
195
+ tool_owners[tool_name] = extension.name
196
+ end
197
+ end
198
+ end
199
+
200
+ def merge_system_prompts(extensions)
201
+ extensions.flat_map(&:system_prompt_additions).reject { |a| a.nil? || a.empty? }
202
+ end
203
+
204
+ def merge_tools(extensions)
205
+ extensions.flat_map(&:tools)
206
+ end
207
+
208
+ def merge_mcp_servers(extensions)
209
+ servers = extensions.flat_map(&:mcp_servers)
210
+ names = servers.map { |s| s[:name] || s["name"] }.compact
211
+ duplicates = names.group_by { |n| n }.select { |_, v| v.size > 1 }.keys
212
+
213
+ unless duplicates.empty?
214
+ raise ConfigurationError,
215
+ "MCP server name conflict across extensions: #{duplicates.join(", ")}"
216
+ end
217
+
218
+ servers
219
+ end
220
+ end
221
+
222
+ class Registry
223
+ def initialize
224
+ @extensions = {}
225
+ end
226
+
227
+ def register(extension, as: nil)
228
+ unless extension.is_a?(Base)
229
+ raise ConfigurationError, "Extension must be an AgentHarness::Extensions::Base instance"
230
+ end
231
+
232
+ key = (as || extension.name).to_sym
233
+ @extensions[key] = extension
234
+ end
235
+
236
+ def fetch(name)
237
+ @extensions.fetch(name.to_sym) do
238
+ raise ConfigurationError, "Unknown extension: #{name}"
239
+ end
240
+ end
241
+
242
+ def registered?(name)
243
+ @extensions.key?(name.to_sym)
244
+ end
245
+
246
+ def all
247
+ @extensions.values.dup
248
+ end
249
+ end
250
+
251
+ module Loader
252
+ module_function
253
+
254
+ def load(path, adapter: nil)
255
+ resolved_path = File.expand_path(path)
256
+ adapter_name = normalize_adapter(adapter, resolved_path)
257
+
258
+ case adapter_name
259
+ when :pi
260
+ Adapters::Pi.load(resolved_path)
261
+ when :skill
262
+ Adapters::Skill.load(resolved_path)
263
+ else
264
+ raise ConfigurationError, "Unknown extension adapter: #{adapter_name.inspect}"
265
+ end
266
+ end
267
+
268
+ def normalize_adapter(adapter, path)
269
+ return adapter.to_sym if adapter
270
+ return :pi if File.directory?(path) && pi_directory?(path)
271
+ return :pi if File.file?(path) && File.extname(path).match?(/\A\.(?:[jt]s|json)\z/i)
272
+ return :skill if File.file?(path) && File.extname(path) == ".md"
273
+ if File.directory?(path)
274
+ raise ConfigurationError, "Cannot infer extension adapter for directory: #{path}"
275
+ end
276
+
277
+ raise ConfigurationError, "Could not infer adapter for extension source: #{path}"
278
+ end
279
+
280
+ def pi_directory?(path)
281
+ return true if File.exist?(File.join(path, "package.json"))
282
+ return true if File.exist?(File.join(path, "index.ts")) || File.exist?(File.join(path, "index.js"))
283
+ return true if File.directory?(File.join(path, "extensions"))
284
+ # Detect bare Pi-style directories containing .ts/.js source files
285
+ # even without package.json or index.ts, since Pi.load can resolve them.
286
+ return true if Dir.glob(File.join(path, "*.{ts,js}")).any?
287
+
288
+ false
289
+ end
290
+
291
+ def discover(directory)
292
+ resolved = File.expand_path(directory)
293
+ return [] unless File.directory?(resolved)
294
+
295
+ extensions = []
296
+
297
+ Dir.glob(File.join(resolved, "*")).sort.each do |child|
298
+ next unless File.directory?(child) || File.file?(child)
299
+
300
+ begin
301
+ extensions.concat(load(child))
302
+ rescue ConfigurationError
303
+ next
304
+ end
305
+ end
306
+
307
+ extensions
308
+ end
309
+ end
310
+
311
+ module Adapters
312
+ class PiExtension < Base
313
+ include DeepDupable
314
+
315
+ attr_reader :name, :description, :version, :entry_paths, :source_path
316
+
317
+ def initialize(name:, source_path:, entry_paths:, description: nil, version: nil, tools: [],
318
+ system_prompt_additions: [], mcp_servers: [], required_provider_capabilities: [],
319
+ unsupported_features: [])
320
+ @name = name.to_s.strip.gsub(/[^a-zA-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "").downcase.to_sym
321
+ @description = description
322
+ @version = version
323
+ @tools = tools.freeze
324
+ @system_prompt_additions = system_prompt_additions.freeze
325
+ @mcp_servers = mcp_servers.freeze
326
+ @required_provider_capabilities = required_provider_capabilities.freeze
327
+ @unsupported_features = unsupported_features.freeze
328
+ @source_path = source_path
329
+ @entry_paths = entry_paths.freeze
330
+ end
331
+
332
+ def tools
333
+ @tools.map(&:dup)
334
+ end
335
+
336
+ def mcp_servers
337
+ @mcp_servers.map { |server| deep_dup(server) }
338
+ end
339
+
340
+ def system_prompt_additions
341
+ @system_prompt_additions.dup
342
+ end
343
+
344
+ def required_provider_capabilities
345
+ inferred = []
346
+ inferred << :tool_use if @tools.any?
347
+ inferred << :mcp if @mcp_servers.any?
348
+ (@required_provider_capabilities + inferred).uniq
349
+ end
350
+
351
+ def unsupported_features
352
+ @unsupported_features.dup
353
+ end
354
+ end
355
+
356
+ module Pi
357
+ module_function
358
+
359
+ def load(path)
360
+ resolved_path = File.expand_path(path)
361
+ single_file = File.file?(resolved_path) && %w[.ts .js].include?(File.extname(resolved_path)) &&
362
+ File.basename(resolved_path) != "package.json"
363
+
364
+ root = resolve_root(resolved_path)
365
+ package = load_package_json(root)
366
+ entry_paths = if single_file
367
+ # When a specific script file is requested, scope to that file only
368
+ # instead of discovering all siblings in the parent directory.
369
+ [resolved_path]
370
+ else
371
+ discover_entry_paths(root, package)
372
+ end
373
+ ext_config = package.fetch("agent_harness", {})
374
+ tools = ext_config["tools"] || discover_tools(entry_paths)
375
+ system_prompt_additions = Array(ext_config["system_prompt_additions"])
376
+ mcp_servers = Array(ext_config["mcp_servers"])
377
+ required_provider_capabilities = Array(ext_config["required_provider_capabilities"])
378
+ # Conservatively require :tool_use when registerTool is called with non-inline
379
+ # arguments that static extraction cannot parse.
380
+ if !ext_config["tools"] && has_non_inline_register_tool_calls?(entry_paths)
381
+ required_provider_capabilities |= ["tool_use"]
382
+ end
383
+ unsupported_features = Array(ext_config["unsupported_features"])
384
+ unsupported_features |= infer_unsupported_features(entry_paths)
385
+
386
+ default_name = single_file ? File.basename(resolved_path, File.extname(resolved_path)) : File.basename(root)
387
+
388
+ [
389
+ PiExtension.new(
390
+ name: ext_config["name"] || package["name"] || default_name,
391
+ description: ext_config["description"] || package["description"],
392
+ version: ext_config["version"] || package["version"],
393
+ tools: tools.map { |tool| normalize_tool(tool) },
394
+ system_prompt_additions: system_prompt_additions,
395
+ mcp_servers: mcp_servers.map { |server| normalize_mcp_server(server) },
396
+ required_provider_capabilities: required_provider_capabilities.map(&:to_sym),
397
+ unsupported_features: unsupported_features.map(&:to_sym),
398
+ source_path: root,
399
+ entry_paths: entry_paths
400
+ )
401
+ ]
402
+ end
403
+
404
+ def resolve_root(path)
405
+ if File.file?(path)
406
+ return File.dirname(path) if File.basename(path) == "package.json"
407
+ return File.dirname(path) if %w[.ts .js].include?(File.extname(path))
408
+ end
409
+
410
+ return path if File.directory?(path)
411
+
412
+ raise ConfigurationError, "Unsupported pi extension source: #{path}"
413
+ end
414
+
415
+ def load_package_json(root)
416
+ package_path = File.join(root, "package.json")
417
+ return {} unless File.exist?(package_path)
418
+
419
+ JSON.parse(File.read(package_path))
420
+ rescue JSON::ParserError => e
421
+ raise ConfigurationError, "Invalid package.json for pi extension at #{root}: #{e.message}"
422
+ end
423
+
424
+ def discover_entry_paths(root, package)
425
+ manifest_entries = Array(package.dig("pi", "extensions"))
426
+ candidates = if manifest_entries.empty?
427
+ convention_extension_paths(root)
428
+ else
429
+ manifest_entries.flat_map { |entry| expand_manifest_entry(root, entry) }
430
+ end
431
+
432
+ paths = candidates.select { |candidate| File.file?(candidate) }
433
+ raise ConfigurationError, "No pi extension entry points found in #{root}" if paths.empty?
434
+
435
+ paths.uniq.sort
436
+ end
437
+
438
+ def convention_extension_paths(root)
439
+ extensions_dir = File.join(root, "extensions")
440
+ return direct_extension_entry_paths(root) unless File.directory?(extensions_dir)
441
+
442
+ direct_extension_entry_paths(extensions_dir)
443
+ end
444
+
445
+ def expand_manifest_entry(root, entry)
446
+ absolute = File.expand_path(entry, root)
447
+ return direct_extension_entry_paths(absolute) if File.directory?(absolute)
448
+ return Dir.glob(absolute).flat_map { |match| direct_extension_entry_paths(match) } unless File.exist?(absolute)
449
+
450
+ direct_extension_entry_paths(absolute)
451
+ end
452
+
453
+ def direct_extension_entry_paths(path)
454
+ if File.file?(path)
455
+ return [path] if extension_script?(path)
456
+ return []
457
+ end
458
+
459
+ return [] unless File.directory?(path)
460
+
461
+ entries = []
462
+ entries.concat(Dir.glob(File.join(path, "*.{ts,js}")))
463
+ Dir.glob(File.join(path, "*")).sort.each do |child|
464
+ next unless File.directory?(child)
465
+
466
+ %w[index.ts index.js].each do |entry|
467
+ entry_path = File.join(child, entry)
468
+ entries << entry_path if File.file?(entry_path)
469
+ end
470
+ end
471
+ entries
472
+ end
473
+
474
+ def extension_script?(path)
475
+ %w[.ts .js].include?(File.extname(path))
476
+ end
477
+
478
+ def discover_tools(entry_paths)
479
+ entry_paths.flat_map do |entry_path|
480
+ source = File.read(entry_path)
481
+ source.scan(/registerTool\s*\(\s*\{(.*?)\}\s*\)/m).filter_map do |match|
482
+ block = match.first
483
+ name = block[/name:\s*["']([^"']+)["']/, 1]
484
+ next unless name
485
+
486
+ description = block[/description:\s*["']([^"']+)["']/, 1]
487
+ {name: name, description: description}.compact
488
+ end
489
+ end.uniq
490
+ end
491
+
492
+ # Detect whether any entry path contains registerTool calls that could
493
+ # not be statically extracted as inline object literals. When true, the
494
+ # extension should conservatively require :tool_use even if discover_tools
495
+ # returned an empty list.
496
+ def has_non_inline_register_tool_calls?(entry_paths)
497
+ entry_paths.any? do |entry_path|
498
+ source = File.read(entry_path)
499
+ total_calls = source.scan(/registerTool\s*\(/).length
500
+ inline_calls = source.scan(/registerTool\s*\(\s*\{/).length
501
+ total_calls > inline_calls
502
+ end
503
+ end
504
+
505
+ def infer_unsupported_features(entry_paths)
506
+ features = []
507
+
508
+ entry_paths.each do |entry_path|
509
+ source = File.read(entry_path)
510
+ features << :commands if source.include?("registerCommand")
511
+ features << :shortcuts if source.include?("registerShortcut")
512
+ features << :ui if source.match?(/ctx\.ui\.|setWidget|setStatus|setTitle/)
513
+ features << :session_persistence if source.match?(/appendEntry|session_start|session_end/)
514
+ end
515
+
516
+ features.uniq
517
+ end
518
+
519
+ def normalize_tool(tool)
520
+ case tool
521
+ when Hash
522
+ tool.transform_keys(&:to_sym)
523
+ when String, Symbol
524
+ {name: tool.to_s}
525
+ else
526
+ raise ConfigurationError, "Unsupported tool definition in pi adapter: #{tool.inspect}"
527
+ end
528
+ end
529
+
530
+ def normalize_mcp_server(server)
531
+ case server
532
+ when Hash
533
+ server.transform_keys(&:to_sym)
534
+ else
535
+ raise ConfigurationError, "Unsupported MCP server definition in pi adapter: #{server.inspect}"
536
+ end
537
+ end
538
+ end
539
+
540
+ class SkillExtension < Base
541
+ include DeepDupable
542
+
543
+ attr_reader :name, :description, :version, :source_path
544
+
545
+ def initialize(name:, source_path:, description: nil, version: nil, tools: [],
546
+ system_prompt_additions: [], mcp_servers: [], required_provider_capabilities: [])
547
+ @name = name.to_s.strip.gsub(/[^a-zA-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "").downcase.to_sym
548
+ @description = description
549
+ @version = version
550
+ @tools = tools.freeze
551
+ @system_prompt_additions = system_prompt_additions.freeze
552
+ @mcp_servers = mcp_servers.freeze
553
+ @required_provider_capabilities = required_provider_capabilities.freeze
554
+ @source_path = source_path
555
+ end
556
+
557
+ def tools
558
+ @tools.map(&:dup)
559
+ end
560
+
561
+ def mcp_servers
562
+ @mcp_servers.map { |server| deep_dup(server) }
563
+ end
564
+
565
+ def system_prompt_additions
566
+ @system_prompt_additions.dup
567
+ end
568
+
569
+ def required_provider_capabilities
570
+ inferred = []
571
+ inferred << :tool_use if @tools.any?
572
+ inferred << :mcp if @mcp_servers.any?
573
+ (@required_provider_capabilities + inferred).uniq
574
+ end
575
+ end
576
+
577
+ module Skill
578
+ module_function
579
+
580
+ def load(path)
581
+ resolved = File.expand_path(path)
582
+ raise ConfigurationError, "Skill file not found: #{resolved}" unless File.file?(resolved)
583
+ raise ConfigurationError, "Skill file must be a Markdown file: #{resolved}" unless File.extname(resolved) == ".md"
584
+
585
+ content = File.read(resolved)
586
+ frontmatter, body = parse_frontmatter(content)
587
+
588
+ tools = Array(frontmatter["tools"]).map { |tool| normalize_tool(tool) }
589
+ mcp_servers = Array(frontmatter["mcp_servers"]).map { |server| normalize_mcp_server(server) }
590
+ instructions = extract_instructions(body)
591
+ system_prompt_additions = instructions.empty? ? [] : [instructions]
592
+
593
+ [
594
+ SkillExtension.new(
595
+ name: frontmatter["name"] || File.basename(resolved, ".md"),
596
+ description: frontmatter["description"],
597
+ version: frontmatter["version"],
598
+ tools: tools,
599
+ system_prompt_additions: system_prompt_additions,
600
+ mcp_servers: mcp_servers,
601
+ required_provider_capabilities: Array(frontmatter["required_provider_capabilities"]).map(&:to_sym),
602
+ source_path: resolved
603
+ )
604
+ ]
605
+ end
606
+
607
+ def parse_frontmatter(content)
608
+ match = content.match(/\A---\s*\n(.*?\n)---\s*\n(.*)\z/m)
609
+ return [{}, content] unless match
610
+
611
+ require "yaml"
612
+ frontmatter = YAML.safe_load(match[1], permitted_classes: [Symbol]) || {}
613
+ [frontmatter, match[2]]
614
+ rescue Psych::SyntaxError => e
615
+ raise ConfigurationError, "Invalid YAML frontmatter in skill file: #{e.message}"
616
+ end
617
+
618
+ def extract_instructions(body)
619
+ body.to_s.strip
620
+ end
621
+
622
+ def normalize_tool(tool)
623
+ case tool
624
+ when Hash
625
+ tool.transform_keys(&:to_sym)
626
+ when String, Symbol
627
+ {name: tool.to_s}
628
+ else
629
+ raise ConfigurationError, "Unsupported tool definition in skill adapter: #{tool.inspect}"
630
+ end
631
+ end
632
+
633
+ def normalize_mcp_server(server)
634
+ case server
635
+ when Hash
636
+ server.transform_keys(&:to_sym)
637
+ else
638
+ raise ConfigurationError, "Unsupported MCP server definition in skill adapter: #{server.inspect}"
639
+ end
640
+ end
641
+ end
642
+ end
643
+ end
644
+ end
@@ -25,6 +25,7 @@ module AgentHarness
25
25
  # end
26
26
  class Base
27
27
  include Adapter
28
+ include Extensions::DeepDupable
28
29
 
29
30
  DEFAULT_SMOKE_TEST_CONTRACT = {
30
31
  prompt: "Reply with exactly OK.",
@@ -76,9 +77,11 @@ module AgentHarness
76
77
  # @param config [ProviderConfig, nil] provider configuration
77
78
  # @param executor [CommandExecutor, nil] command executor
78
79
  # @param logger [Logger, nil] logger instance
79
- def initialize(config: nil, executor: nil, logger: nil)
80
+ # @param configuration [Configuration, nil] parent configuration for extension/sub-agent resolution
81
+ def initialize(config: nil, executor: nil, logger: nil, configuration: nil)
80
82
  @config = config || ProviderConfig.new(self.class.provider_name)
81
- @executor = executor || AgentHarness.configuration.command_executor
83
+ @configuration = configuration || AgentHarness.configuration
84
+ @executor = executor || @configuration.command_executor
82
85
  @logger = logger || AgentHarness.logger
83
86
  end
84
87
 
@@ -126,6 +129,15 @@ module AgentHarness
126
129
 
127
130
  # Coerce provider_runtime from Hash if needed
128
131
  options = normalize_provider_runtime(options)
132
+
133
+ # Capture execution options (callbacks, observer) before extensions
134
+ # processing deep-dups the options hash, which would replace identity-
135
+ # sensitive references (observers, procs) with clones.
136
+ exec_opts = command_execution_options(options)
137
+
138
+ extension_context = apply_extensions_to_prompt(prompt, options)
139
+ prompt = extension_context.prompt
140
+ options = extension_context.options
129
141
  options = normalize_sub_agent(options)
130
142
  prompt = apply_sub_agent_to_prompt(prompt, options[:translated_sub_agent])
131
143
 
@@ -147,7 +159,7 @@ module AgentHarness
147
159
  timeout: timeout,
148
160
  env: build_env(options),
149
161
  preparation: preparation,
150
- **command_execution_options(options)
162
+ **exec_opts
151
163
  )
152
164
  duration = Time.now - start_time
153
165
 
@@ -171,13 +183,15 @@ module AgentHarness
171
183
  )
172
184
  end
173
185
 
186
+ response = apply_extensions_after_response(extension_context, response)
187
+
174
188
  # Track tokens
175
189
  track_tokens(response) if response.tokens
176
190
 
177
191
  log_debug("send_message_complete", duration: duration, tokens: response.tokens)
178
192
 
179
193
  response
180
- rescue McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
194
+ rescue ExtensionCompatibilityError, McpConfigurationError, McpUnsupportedError, McpTransportUnsupportedError
181
195
  raise
182
196
  rescue => e
183
197
  handle_error(e, prompt: prompt, options: options)
@@ -220,7 +234,12 @@ module AgentHarness
220
234
 
221
235
  transport = resolve_chat_transport(options)
222
236
  messages = format_messages_for_transport(conversation, transport)
237
+ extension_context = apply_extensions_to_chat(messages, tools, options)
238
+ messages = extension_context.messages
239
+ tools = extension_context.tools
240
+ options = extension_context.options
223
241
  messages = apply_sub_agent_to_messages(messages, options[:translated_sub_agent])
242
+ validate_chat_mcp_servers!(options[:mcp_servers])
224
243
  transport_opts = chat_transport_options(runtime, options)
225
244
  transport_opts[:on_chat_chunk] = on_chat_chunk if on_chat_chunk
226
245
  transport_opts[:observer] = observer if observer
@@ -233,11 +252,13 @@ module AgentHarness
233
252
  &on_chunk
234
253
  )
235
254
 
255
+ response = apply_extensions_after_response(extension_context, response)
256
+
236
257
  track_tokens(response) if response.tokens
237
258
  log_debug("send_chat_message_complete", duration: response.duration, tokens: response.tokens)
238
259
 
239
260
  response
240
- rescue ProviderError, AuthenticationError, RateLimitError, TimeoutError
261
+ rescue ExtensionCompatibilityError, ProviderError, AuthenticationError, RateLimitError, TimeoutError
241
262
  raise
242
263
  rescue => e
243
264
  last_msg = conversation&.last || messages&.last
@@ -423,7 +444,7 @@ module AgentHarness
423
444
  servers = options[:mcp_servers]
424
445
  else
425
446
  # Configuration stores mcp_servers as a Hash keyed by name; extract values.
426
- config_servers = AgentHarness.configuration.mcp_servers
447
+ config_servers = @configuration.mcp_servers
427
448
  servers = config_servers.is_a?(Hash) ? config_servers.values : config_servers
428
449
  end
429
450
  return options if servers.nil?
@@ -460,12 +481,12 @@ module AgentHarness
460
481
  sub_agent = options[:sub_agent]
461
482
  return options unless sub_agent
462
483
 
463
- resolved = AgentHarness.configuration.resolve_sub_agent(sub_agent)
484
+ resolved = @configuration.resolve_sub_agent(sub_agent)
464
485
  translated = SubAgentTranslator.for_provider(
465
486
  self.class.provider_name,
466
487
  resolved,
467
- tool_registry: AgentHarness.configuration.tool_registry,
468
- mcp_servers: AgentHarness.configuration.mcp_servers
488
+ tool_registry: @configuration.tool_registry,
489
+ mcp_servers: @configuration.mcp_servers
469
490
  )
470
491
 
471
492
  options.merge(sub_agent: resolved, translated_sub_agent: translated)
@@ -483,6 +504,182 @@ module AgentHarness
483
504
  [{role: "system", content: translated_sub_agent[:runtime_instructions]}] + messages
484
505
  end
485
506
 
507
+ def resolve_extensions(options)
508
+ Array(options[:extensions]).filter_map do |reference|
509
+ @configuration.resolve_extension(reference)
510
+ end
511
+ end
512
+
513
+ def apply_extensions_to_prompt(prompt, options)
514
+ extensions = resolve_extensions(options)
515
+ strict = options.fetch(:extensions_strict, true)
516
+ Extensions::Composition.compose(extensions) if extensions.size > 1
517
+ context = Extensions::MessageContext.new(
518
+ provider: self,
519
+ extensions: extensions,
520
+ mode: :message,
521
+ prompt: prompt,
522
+ options: deep_dup(options),
523
+ metadata: {}
524
+ )
525
+
526
+ validate_extensions!(extensions, strict: strict)
527
+ reject_tool_extensions_in_message_mode!(extensions)
528
+ reject_mcp_extensions_in_message_mode!(extensions)
529
+ apply_extension_system_prompt!(context)
530
+ extensions.each { |extension| extension.on_message_before(context) }
531
+ context
532
+ end
533
+
534
+ def apply_extensions_to_chat(messages, tools, options)
535
+ extensions = resolve_extensions(options)
536
+ strict = options.fetch(:extensions_strict, true)
537
+ Extensions::Composition.compose(extensions) if extensions.size > 1
538
+ context = Extensions::MessageContext.new(
539
+ provider: self,
540
+ extensions: extensions,
541
+ mode: :chat,
542
+ messages: deep_dup(messages),
543
+ tools: merge_extension_tools(tools, extensions),
544
+ options: deep_dup(options),
545
+ metadata: {}
546
+ )
547
+
548
+ validate_extensions!(extensions, strict: strict)
549
+ merge_extension_mcp_servers!(context)
550
+ apply_extension_system_messages!(context)
551
+ extensions.each { |extension| extension.on_message_before(context) }
552
+ extensions.each { |extension| extension.on_tools_available(context) } if context.tools&.any?
553
+ context
554
+ end
555
+
556
+ def apply_extensions_after_response(context, response)
557
+ return response unless context
558
+
559
+ context.response = response
560
+ context.extensions.each { |extension| extension.on_message_after(context) }
561
+ context.response
562
+ end
563
+
564
+ def validate_extensions!(extensions, strict: true)
565
+ extensions.each do |extension|
566
+ report = Extensions::Compatibility.check!(provider: self, extension: extension, strict: strict)
567
+ next if report.fully_supported?
568
+
569
+ @logger&.warn(
570
+ "[AgentHarness::#{self.class.provider_name}] Extension '#{extension.name}' has " \
571
+ "unsupported features that will be unavailable: #{report.unsupported_features.inspect}"
572
+ )
573
+ end
574
+ end
575
+
576
+ def validate_chat_mcp_servers!(mcp_servers)
577
+ return if mcp_servers.nil? || mcp_servers.empty?
578
+
579
+ # Chat transports do not support request-scoped MCP servers.
580
+ # Raise early so extensions with MCP requirements are not silently ignored.
581
+ raise McpUnsupportedError.new(
582
+ "Chat mode does not support request-scoped MCP servers. " \
583
+ "Extensions or options requiring MCP servers cannot be used with send_chat_message.",
584
+ provider: self.class.provider_name
585
+ )
586
+ end
587
+
588
+ def reject_tool_extensions_in_message_mode!(extensions)
589
+ tool_extensions = extensions.select { |ext| ext.tools.any? }
590
+ return if tool_extensions.empty?
591
+
592
+ names = tool_extensions.map(&:name).join(", ")
593
+ raise ExtensionCompatibilityError.new(
594
+ "Extensions with tools are not supported in message mode (CLI execution " \
595
+ "cannot accept dynamic tool definitions): #{names}",
596
+ provider: self.class.provider_name
597
+ )
598
+ end
599
+
600
+ def reject_mcp_extensions_in_message_mode!(extensions)
601
+ mcp_extensions = extensions.select { |ext| ext.mcp_servers.any? }
602
+ return if mcp_extensions.empty?
603
+
604
+ names = mcp_extensions.map(&:name).join(", ")
605
+ raise ExtensionCompatibilityError.new(
606
+ "Extensions with MCP servers are not supported in message mode: #{names}",
607
+ provider: self.class.provider_name
608
+ )
609
+ end
610
+
611
+ def merge_extension_mcp_servers!(context)
612
+ extension_servers = context.extensions.flat_map(&:mcp_servers)
613
+ return if extension_servers.empty?
614
+
615
+ merged = Array(context.options[:mcp_servers]) + extension_servers
616
+ context.options = context.options.merge(mcp_servers: merged)
617
+ end
618
+
619
+ def merge_extension_tools(tools, extensions)
620
+ extension_tools = extensions.flat_map(&:tools)
621
+ return tools unless extension_tools.any?
622
+
623
+ normalized_extension_tools = extension_tools.map { |t| normalize_extension_tool_for_provider(t) }
624
+ merged = Array(tools) + normalized_extension_tools
625
+ names = merged.map { |t| extract_tool_name(t) }.compact
626
+ duplicates = names.group_by { |n| n }.select { |_, v| v.size > 1 }.keys
627
+ unless duplicates.empty?
628
+ raise ConfigurationError,
629
+ "Tool name conflict between user-provided and extension tools: #{duplicates.join(", ")}"
630
+ end
631
+ merged
632
+ end
633
+
634
+ def extract_tool_name(tool)
635
+ tool[:name] || tool["name"] ||
636
+ tool.dig(:function, :name) || tool.dig(:function, "name") ||
637
+ tool.dig("function", "name") || tool.dig("function", :name)
638
+ end
639
+
640
+ def normalize_extension_tool_for_provider(tool)
641
+ case chat_transport_type
642
+ when :openai_compatible
643
+ # OpenAI-style: {type: "function", function: {name: ..., description: ...}}
644
+ return tool if tool[:type] == "function" || tool["type"] == "function"
645
+
646
+ func = {name: tool[:name] || tool["name"]}
647
+ description = tool[:description] || tool["description"]
648
+ func[:description] = description if description
649
+ parameters = tool[:parameters] || tool["parameters"]
650
+ func[:parameters] = parameters if parameters
651
+ {type: "function", function: func}
652
+ else
653
+ # Anthropic-style: {name: ..., description: ..., input_schema: ...}
654
+ # Convert `parameters` key to `input_schema` for Anthropic/TextTransport compatibility.
655
+ normalized = tool.dup
656
+ params = normalized.delete(:parameters) || normalized.delete("parameters")
657
+ if params && !normalized.key?(:input_schema) && !normalized.key?("input_schema")
658
+ normalized[:input_schema] = params
659
+ end
660
+ normalized
661
+ end
662
+ end
663
+
664
+ def apply_extension_system_prompt!(context)
665
+ additions = context.extensions.flat_map(&:system_prompt_additions).reject do |addition|
666
+ addition.nil? || addition.empty?
667
+ end
668
+ return if additions.empty?
669
+
670
+ context.prompt = [additions.join("\n\n"), context.prompt].join("\n\n")
671
+ end
672
+
673
+ def apply_extension_system_messages!(context)
674
+ additions = context.extensions.flat_map(&:system_prompt_additions).reject do |addition|
675
+ addition.nil? || addition.empty?
676
+ end
677
+ return if additions.empty?
678
+
679
+ system_messages = additions.map { |addition| {role: "system", content: addition} }
680
+ context.messages = system_messages + context.messages
681
+ end
682
+
486
683
  def command_execution_options(options)
487
684
  execution_options = {
488
685
  idle_timeout: options[:idle_timeout],
@@ -11,6 +11,11 @@ module AgentHarness
11
11
  include RateLimitResetParsing
12
12
  include McpConfigFileSupport
13
13
 
14
+ StreamingEvent = Struct.new(
15
+ :type, :turn, :tokens, :error_message, :tool_name, :raw_event,
16
+ keyword_init: true
17
+ )
18
+
14
19
  SUPPORTED_CLI_VERSION = "0.116.0"
15
20
  SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 0.117.0").freeze
16
21
  OAUTH_REFRESH_FAILURE_PATTERNS = [
@@ -142,15 +147,31 @@ module AgentHarness
142
147
  end
143
148
 
144
149
  def parse_cli_jsonl_transcript(raw_output, max_events: nil)
145
- return new.send(:parse_jsonl_output, "") if max_events && max_events <= 0
150
+ return parser_instance.send(:parse_jsonl_output, "") if max_events && max_events <= 0
146
151
 
147
152
  output = max_events ? tail_nonempty_lines(raw_output, limit: max_events).join("\n") : raw_output
148
153
 
149
- new.send(:parse_jsonl_output, output)
154
+ parser_instance.send(:parse_jsonl_output, output)
155
+ end
156
+
157
+ # Parse a single Codex JSONL event as it arrives on stdout and classify it
158
+ # for real-time progress tracking. Returns nil for malformed JSON, scalar
159
+ # JSON values, plain-text output, or unsupported event types.
160
+ def parse_streaming_event(line)
161
+ event = JSON.parse(line.to_s)
162
+ return unless event.is_a?(Hash)
163
+
164
+ parser_instance.send(:build_streaming_event, event)
165
+ rescue JSON::ParserError, TypeError
166
+ nil
150
167
  end
151
168
 
152
169
  private
153
170
 
171
+ def parser_instance
172
+ @parser_instance ||= allocate.freeze
173
+ end
174
+
154
175
  def tail_nonempty_lines(text, limit:)
155
176
  return [] if limit <= 0
156
177
 
@@ -507,6 +528,152 @@ module AgentHarness
507
528
 
508
529
  private
509
530
 
531
+ def build_streaming_event(event)
532
+ raw_event, payload, dispatch_type = unwrap_streaming_event(event)
533
+ return unless payload.is_a?(Hash)
534
+
535
+ case dispatch_type
536
+ when "message.delta", "agent_message_delta"
537
+ build_progress_streaming_event(raw_event, payload)
538
+ when "turn.completed", "task_complete", "turn_complete"
539
+ build_turn_complete_streaming_event(raw_event, payload)
540
+ when "turn.failed"
541
+ build_error_streaming_event(raw_event, payload)
542
+ when "item.completed", "response_item", "agent_message"
543
+ build_item_streaming_event(raw_event, payload)
544
+ when "token_count"
545
+ build_token_usage_streaming_event(raw_event, payload)
546
+ end
547
+ end
548
+
549
+ def unwrap_streaming_event(event)
550
+ event_type = event["type"]
551
+
552
+ if event_type == "event_msg"
553
+ payload = event["payload"]
554
+ [event, payload, payload.is_a?(Hash) ? payload["type"] : nil]
555
+ elsif event_type == "response_item"
556
+ # Preserve the original "response_item" dispatch type so
557
+ # build_streaming_event routes to build_item_streaming_event
558
+ # even after unwrapping the inner payload.
559
+ [event, event["payload"], "response_item"]
560
+ else
561
+ [event, event, event_type]
562
+ end
563
+ end
564
+
565
+ def build_progress_streaming_event(raw_event, payload)
566
+ return unless progress_payload?(payload)
567
+
568
+ StreamingEvent.new(
569
+ type: :progress,
570
+ turn: extract_streaming_turn(payload),
571
+ raw_event: raw_event
572
+ )
573
+ end
574
+
575
+ def build_turn_complete_streaming_event(raw_event, payload)
576
+ StreamingEvent.new(
577
+ type: :turn_complete,
578
+ turn: extract_streaming_turn(payload),
579
+ tokens: compact_streaming_tokens(build_token_usage(payload["usage"])),
580
+ raw_event: raw_event
581
+ )
582
+ end
583
+
584
+ def build_error_streaming_event(raw_event, payload)
585
+ StreamingEvent.new(
586
+ type: :error,
587
+ turn: extract_streaming_turn(payload),
588
+ tokens: compact_streaming_tokens(build_token_usage(payload["usage"])),
589
+ error_message: extract_error_message(payload),
590
+ raw_event: raw_event
591
+ )
592
+ end
593
+
594
+ def build_item_streaming_event(raw_event, payload)
595
+ item = payload["item"].is_a?(Hash) ? payload["item"] : payload
596
+
597
+ if tool_use_payload?(item)
598
+ return StreamingEvent.new(
599
+ type: :tool_use,
600
+ turn: extract_streaming_turn(payload),
601
+ tool_name: extract_tool_name(item),
602
+ raw_event: raw_event
603
+ )
604
+ end
605
+
606
+ return unless assistant_message_item?(item) || response_item_assistant_payload?(item) || wrapped_assistant_payload?(item)
607
+
608
+ StreamingEvent.new(
609
+ type: :progress,
610
+ turn: extract_streaming_turn(payload),
611
+ raw_event: raw_event
612
+ )
613
+ end
614
+
615
+ def build_token_usage_streaming_event(raw_event, payload)
616
+ wrapped_token_usage = extract_wrapped_tokens(payload["info"])
617
+ usage = wrapped_token_usage&.fetch(:last, nil) || wrapped_token_usage&.fetch(:total, nil)
618
+ return unless usage
619
+
620
+ StreamingEvent.new(
621
+ type: :token_usage,
622
+ turn: extract_streaming_turn(payload),
623
+ tokens: compact_streaming_tokens(usage),
624
+ raw_event: raw_event
625
+ )
626
+ end
627
+
628
+ def progress_payload?(payload)
629
+ case payload["type"]
630
+ when "message.delta"
631
+ payload["delta"].is_a?(Hash)
632
+ when "agent_message_delta"
633
+ wrapped_assistant_payload?(payload)
634
+ else
635
+ false
636
+ end
637
+ end
638
+
639
+ def tool_use_payload?(item)
640
+ item.is_a?(Hash) && item["type"] == "tool_call"
641
+ end
642
+
643
+ def extract_tool_name(item)
644
+ item["tool_name"] || item["name"] || item.dig("function", "name") || item.dig("call", "name")
645
+ end
646
+
647
+ def extract_streaming_turn(payload)
648
+ value = payload["turn"] || payload["turn_id"] || payload["turn_index"] || payload.dig("context", "turn")
649
+ return value if value.is_a?(Integer)
650
+
651
+ value.to_i if value.is_a?(String) && /\A\d+\z/.match?(value.strip)
652
+ end
653
+
654
+ def compact_streaming_tokens(usage)
655
+ return unless usage
656
+
657
+ {
658
+ input: usage[:input],
659
+ output: usage[:output],
660
+ total: usage[:total]
661
+ }
662
+ end
663
+
664
+ def extract_error_message(payload)
665
+ error = payload["error"]
666
+
667
+ case error
668
+ when String
669
+ error
670
+ when Hash
671
+ error["message"] || error["error"] || error["detail"]
672
+ else
673
+ payload["message"]
674
+ end
675
+ end
676
+
510
677
  def escape_toml_string(val)
511
678
  val.to_s.gsub("\\") { "\\\\" }.gsub('"') { "\\\"" }.gsub("\n") { "\\n" }
512
679
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.11.2"
4
+ VERSION = "0.12.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -77,6 +77,47 @@ module AgentHarness
77
77
  conductor.send_message(prompt, provider: provider, executor: executor, **options)
78
78
  end
79
79
 
80
+ # Resolve a canonical extension definition by name or inline object.
81
+ #
82
+ # @param reference [Symbol, String, Extensions::Base]
83
+ # @return [Extensions::Base]
84
+ def extension(reference)
85
+ configuration.resolve_extension(reference)
86
+ end
87
+
88
+ # Load one or more extensions from disk through an adapter.
89
+ #
90
+ # @param path [String] extension file, directory, or package root
91
+ # @param adapter [Symbol, String, nil] optional explicit adapter
92
+ # @return [Array<Extensions::Base>]
93
+ def load_extensions(path, adapter: nil)
94
+ configuration.load_extensions(path, adapter: adapter)
95
+ end
96
+
97
+ # Discover and register all extensions found in a directory.
98
+ #
99
+ # @param directory [String] directory to scan
100
+ # @return [Array<Extensions::Base>]
101
+ def discover_extensions(directory)
102
+ configuration.discover_extensions(directory)
103
+ end
104
+
105
+ # Build a compatibility report for extensions against a provider.
106
+ #
107
+ # @param provider [Symbol, String, Providers::Base] target provider
108
+ # @param extensions [Array<Symbol, String, Extensions::Base>] extension references
109
+ # @return [Array<Extensions::CompatibilityReport>]
110
+ def extension_compatibility(provider:, extensions:)
111
+ provider_instance = provider.is_a?(Providers::Base) ? provider : self.provider(provider)
112
+
113
+ Array(extensions).map do |extension_ref|
114
+ Extensions::Compatibility.report(
115
+ provider: provider_instance,
116
+ extension: extension(extension_ref)
117
+ )
118
+ end
119
+ end
120
+
80
121
  # Resolve a canonical sub-agent definition by name or inline payload.
81
122
  #
82
123
  # @param reference [Symbol, String, Hash, SubAgentConfig]
@@ -277,6 +318,7 @@ end
277
318
 
278
319
  # Core components
279
320
  require_relative "agent_harness/errors"
321
+ require_relative "agent_harness/extensions"
280
322
  require_relative "agent_harness/mcp_server"
281
323
  require_relative "agent_harness/mcp_config_loader"
282
324
  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.11.2
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -107,6 +107,7 @@ files:
107
107
  - lib/agent_harness/error_taxonomy.rb
108
108
  - lib/agent_harness/errors.rb
109
109
  - lib/agent_harness/execution_preparation.rb
110
+ - lib/agent_harness/extensions.rb
110
111
  - lib/agent_harness/mcp_config_loader.rb
111
112
  - lib/agent_harness/mcp_config_translator.rb
112
113
  - lib/agent_harness/mcp_server.rb