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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/lib/agent_harness/configuration.rb +52 -1
- data/lib/agent_harness/errors.rb +11 -0
- data/lib/agent_harness/extensions.rb +644 -0
- data/lib/agent_harness/providers/base.rb +206 -9
- data/lib/agent_harness/providers/codex.rb +169 -2
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +42 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e1a243bc20db360b93c22248fd0b35864a995dd6a78def3fdca291d5e39809d
|
|
4
|
+
data.tar.gz: 591bc4baf70eeb598b4c7a6736b4c0090b7108c6f34628b75a1b5b68eb991805
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1028da8c56be8d3f7e948dd23bfd25fbfae3515d1f678fe5c9b5172f5f5a82dbd0fad959ef94d2681619fdf44a6d509dcfc925bc20fb2d3e4e2c92f44ea19d9a
|
|
7
|
+
data.tar.gz: f8ed1ca7bccfa683eee9303a977920d177a62832b48d937b01c107407890ea60b17680030cf4bc8ca3cd3bdf495def3d90518da89943d55306239ee15869c0c2
|
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
|
data/lib/agent_harness/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
@
|
|
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
|
-
**
|
|
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 =
|
|
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 =
|
|
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:
|
|
468
|
-
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
|
|
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
|
-
|
|
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
|
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.
|
|
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
|