agent-harness 0.10.0 → 0.11.1
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 +18 -0
- data/lib/agent_harness/configuration.rb +137 -0
- data/lib/agent_harness/conversation.rb +326 -0
- data/lib/agent_harness/mcp_server.rb +32 -0
- data/lib/agent_harness/openai_compatible_transport.rb +391 -0
- data/lib/agent_harness/provider_runtime.rb +40 -4
- data/lib/agent_harness/providers/adapter.rb +62 -3
- data/lib/agent_harness/providers/anthropic.rb +30 -0
- data/lib/agent_harness/providers/base.rb +173 -0
- data/lib/agent_harness/providers/github_copilot.rb +61 -0
- data/lib/agent_harness/sub_agent_config.rb +118 -0
- data/lib/agent_harness/sub_agent_file_loader.rb +55 -0
- data/lib/agent_harness/sub_agent_translator.rb +243 -0
- data/lib/agent_harness/text_transport.rb +320 -13
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +22 -0
- metadata +6 -1
|
@@ -126,6 +126,8 @@ module AgentHarness
|
|
|
126
126
|
|
|
127
127
|
# Coerce provider_runtime from Hash if needed
|
|
128
128
|
options = normalize_provider_runtime(options)
|
|
129
|
+
options = normalize_sub_agent(options)
|
|
130
|
+
prompt = apply_sub_agent_to_prompt(prompt, options[:translated_sub_agent])
|
|
129
131
|
|
|
130
132
|
# Normalize and validate MCP servers
|
|
131
133
|
options = normalize_mcp_servers(options)
|
|
@@ -181,6 +183,67 @@ module AgentHarness
|
|
|
181
183
|
handle_error(e, prompt: prompt, options: options)
|
|
182
184
|
end
|
|
183
185
|
|
|
186
|
+
# Send a multi-turn chat message via the provider's chat transport.
|
|
187
|
+
#
|
|
188
|
+
# Providers that support chat mode can accept either +conversation:+
|
|
189
|
+
# or +messages:+ as the conversation history payload.
|
|
190
|
+
#
|
|
191
|
+
# Structured streaming events are delivered through three channels:
|
|
192
|
+
# - +on_chat_chunk+ proc (keyword argument)
|
|
193
|
+
# - +observer+ object responding to +on_chat_chunk+
|
|
194
|
+
# - block (yield)
|
|
195
|
+
#
|
|
196
|
+
# When multiple receivers are provided, all receive every event.
|
|
197
|
+
#
|
|
198
|
+
# @param conversation [Array<Hash>, nil] message history
|
|
199
|
+
# @param messages [Array<Hash>, nil] alias for +conversation+
|
|
200
|
+
# @param tools [Array<Hash>, nil] tool/function definitions
|
|
201
|
+
# @param stream [Boolean] whether to stream the response
|
|
202
|
+
# @param on_chat_chunk [Proc, nil] callback for structured streaming events
|
|
203
|
+
# @param observer [#on_chat_chunk, nil] observer receiving streaming events
|
|
204
|
+
# @param options [Hash] additional options
|
|
205
|
+
# @yield [Hash] streaming chunks when stream: true
|
|
206
|
+
# @return [Response] the response
|
|
207
|
+
# @raise [ProviderError] if the provider does not support chat mode
|
|
208
|
+
def send_chat_message(conversation: nil, messages: nil, tools: nil, stream: false,
|
|
209
|
+
on_chat_chunk: nil, observer: nil, **options, &on_chunk)
|
|
210
|
+
unless supports_chat?
|
|
211
|
+
raise ProviderError, "#{name} does not support chat mode"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
options = normalize_provider_runtime(options)
|
|
215
|
+
options = normalize_sub_agent(options)
|
|
216
|
+
runtime = options[:provider_runtime]
|
|
217
|
+
conversation ||= messages
|
|
218
|
+
raise ArgumentError, "conversation or messages is required" unless conversation
|
|
219
|
+
tools = runtime.chat_tools if tools.nil? && runtime&.chat_tools
|
|
220
|
+
|
|
221
|
+
transport = resolve_chat_transport(options)
|
|
222
|
+
messages = format_messages_for_transport(conversation, transport)
|
|
223
|
+
messages = apply_sub_agent_to_messages(messages, options[:translated_sub_agent])
|
|
224
|
+
transport_opts = chat_transport_options(runtime, options)
|
|
225
|
+
transport_opts[:on_chat_chunk] = on_chat_chunk if on_chat_chunk
|
|
226
|
+
transport_opts[:observer] = observer if observer
|
|
227
|
+
|
|
228
|
+
response = transport.chat(
|
|
229
|
+
messages: messages,
|
|
230
|
+
tools: tools,
|
|
231
|
+
stream: stream,
|
|
232
|
+
**transport_opts,
|
|
233
|
+
&on_chunk
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
track_tokens(response) if response.tokens
|
|
237
|
+
log_debug("send_chat_message_complete", duration: response.duration, tokens: response.tokens)
|
|
238
|
+
|
|
239
|
+
response
|
|
240
|
+
rescue ProviderError, AuthenticationError, RateLimitError, TimeoutError
|
|
241
|
+
raise
|
|
242
|
+
rescue => e
|
|
243
|
+
last_msg = conversation&.last || messages&.last
|
|
244
|
+
handle_error(e, prompt: (last_msg&.dig(:content) || last_msg&.dig("content")).to_s, options: options)
|
|
245
|
+
end
|
|
246
|
+
|
|
184
247
|
# Provider name for display
|
|
185
248
|
#
|
|
186
249
|
# @return [String] display name
|
|
@@ -387,6 +450,33 @@ module AgentHarness
|
|
|
387
450
|
options.merge(mcp_servers: normalized)
|
|
388
451
|
end
|
|
389
452
|
|
|
453
|
+
def normalize_sub_agent(options)
|
|
454
|
+
sub_agent = options[:sub_agent]
|
|
455
|
+
return options unless sub_agent
|
|
456
|
+
|
|
457
|
+
resolved = AgentHarness.configuration.resolve_sub_agent(sub_agent)
|
|
458
|
+
translated = SubAgentTranslator.for_provider(
|
|
459
|
+
self.class.provider_name,
|
|
460
|
+
resolved,
|
|
461
|
+
tool_registry: AgentHarness.configuration.tool_registry,
|
|
462
|
+
mcp_servers: AgentHarness.configuration.mcp_servers
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
options.merge(sub_agent: resolved, translated_sub_agent: translated)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def apply_sub_agent_to_prompt(prompt, translated_sub_agent)
|
|
469
|
+
return prompt unless translated_sub_agent
|
|
470
|
+
|
|
471
|
+
[translated_sub_agent[:runtime_instructions], "User task:\n#{prompt}"].join("\n\n")
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def apply_sub_agent_to_messages(messages, translated_sub_agent)
|
|
475
|
+
return messages unless translated_sub_agent
|
|
476
|
+
|
|
477
|
+
[{role: "system", content: translated_sub_agent[:runtime_instructions]}] + messages
|
|
478
|
+
end
|
|
479
|
+
|
|
390
480
|
def command_execution_options(options)
|
|
391
481
|
execution_options = {
|
|
392
482
|
idle_timeout: options[:idle_timeout],
|
|
@@ -466,6 +556,89 @@ module AgentHarness
|
|
|
466
556
|
end
|
|
467
557
|
end
|
|
468
558
|
|
|
559
|
+
def resolve_chat_transport(options)
|
|
560
|
+
runtime = options[:provider_runtime]
|
|
561
|
+
|
|
562
|
+
# When the runtime specifies chat-specific overrides (base_url, api_key),
|
|
563
|
+
# build a fresh transport instead of reusing the memoized default.
|
|
564
|
+
if runtime && (runtime.chat_base_url || runtime.chat_api_key)
|
|
565
|
+
transport = build_runtime_chat_transport(runtime)
|
|
566
|
+
if transport
|
|
567
|
+
return transport
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
transport = chat_transport
|
|
572
|
+
raise ProviderError, "#{name} chat_transport returned nil" unless transport
|
|
573
|
+
|
|
574
|
+
transport
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# Build a one-off chat transport from ProviderRuntime overrides.
|
|
578
|
+
#
|
|
579
|
+
# Subclasses that support chat must override this when the runtime
|
|
580
|
+
# carries chat_base_url or chat_api_key so those overrides are
|
|
581
|
+
# actually applied. The base implementation raises to surface the
|
|
582
|
+
# misconfiguration early rather than silently ignoring the overrides.
|
|
583
|
+
def build_runtime_chat_transport(_runtime)
|
|
584
|
+
raise ProviderError,
|
|
585
|
+
"#{name} does not support chat_base_url/chat_api_key overrides on ProviderRuntime"
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def format_messages_for_transport(conversation, transport)
|
|
589
|
+
normalized = conversation.map { |msg| normalize_transport_message(msg) }
|
|
590
|
+
return normalized unless anthropic_transport?(transport)
|
|
591
|
+
return normalized unless anthropic_conversion_required?(normalized)
|
|
592
|
+
|
|
593
|
+
anthropic = anthropic_conversation(normalized)
|
|
594
|
+
system_messages = anthropic[:system] ? [{role: "system", content: anthropic[:system]}] : []
|
|
595
|
+
|
|
596
|
+
system_messages + anthropic[:messages]
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def normalize_transport_message(message)
|
|
600
|
+
message.each_with_object({}) do |(key, value), memo|
|
|
601
|
+
memo[key.is_a?(String) ? key.to_sym : key] = value
|
|
602
|
+
end.tap do |normalized|
|
|
603
|
+
normalized[:role] = normalized[:role].to_s if normalized.key?(:role)
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def anthropic_transport?(transport)
|
|
608
|
+
chat_transport_type == :anthropic || transport.is_a?(TextTransport)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def anthropic_conversion_required?(messages)
|
|
612
|
+
messages.any? do |msg|
|
|
613
|
+
msg[:role] == "tool" || msg.key?(:tool_calls)
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def anthropic_conversation(messages)
|
|
618
|
+
conversation = Conversation.new
|
|
619
|
+
|
|
620
|
+
messages.each do |msg|
|
|
621
|
+
conversation.add_message(
|
|
622
|
+
msg.fetch(:role).to_sym,
|
|
623
|
+
msg[:content],
|
|
624
|
+
tool_calls: msg[:tool_calls],
|
|
625
|
+
tool_call_id: msg[:tool_call_id]
|
|
626
|
+
)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
conversation.to_anthropic_messages
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def chat_transport_options(runtime, options)
|
|
633
|
+
opts = {}
|
|
634
|
+
max_tok = options[:chat_max_tokens] || options[:max_tokens] || runtime&.chat_max_tokens
|
|
635
|
+
opts[:max_tokens] = max_tok if max_tok
|
|
636
|
+
model = runtime&.chat_model || runtime&.model
|
|
637
|
+
opts[:model] = model if model
|
|
638
|
+
opts[:temperature] = options[:temperature] if options[:temperature]
|
|
639
|
+
opts
|
|
640
|
+
end
|
|
641
|
+
|
|
469
642
|
def log_debug(action, **context)
|
|
470
643
|
@logger&.debug("[AgentHarness::#{self.class.provider_name}] #{action}: #{context.inspect}")
|
|
471
644
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
|
+
require "pathname"
|
|
5
6
|
|
|
6
7
|
module AgentHarness
|
|
7
8
|
module Providers
|
|
@@ -97,6 +98,10 @@ module AgentHarness
|
|
|
97
98
|
]
|
|
98
99
|
end
|
|
99
100
|
|
|
101
|
+
def supports_chat?
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
100
105
|
def smoke_test_contract
|
|
101
106
|
SMOKE_TEST_CONTRACT
|
|
102
107
|
end
|
|
@@ -194,6 +199,40 @@ module AgentHarness
|
|
|
194
199
|
["--resume", session_id]
|
|
195
200
|
end
|
|
196
201
|
|
|
202
|
+
GITHUB_MODELS_BASE_URL = "https://models.inference.ai.azure.com"
|
|
203
|
+
CHAT_DEFAULT_MODEL = "gpt-4o"
|
|
204
|
+
CHAT_MODELS = %w[gpt-4o gpt-4o-mini gpt-4-turbo].freeze
|
|
205
|
+
|
|
206
|
+
def supports_chat?
|
|
207
|
+
true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def chat_models
|
|
211
|
+
CHAT_MODELS
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def chat_transport
|
|
215
|
+
@chat_transport ||= OpenAICompatibleTransport.new(
|
|
216
|
+
base_url: GITHUB_MODELS_BASE_URL,
|
|
217
|
+
api_key: resolve_chat_api_key,
|
|
218
|
+
model: CHAT_DEFAULT_MODEL,
|
|
219
|
+
logger: @logger
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def build_runtime_chat_transport(runtime)
|
|
224
|
+
OpenAICompatibleTransport.new(
|
|
225
|
+
base_url: runtime.chat_base_url || GITHUB_MODELS_BASE_URL,
|
|
226
|
+
api_key: runtime.chat_api_key || resolve_chat_api_key,
|
|
227
|
+
model: runtime.chat_model || runtime.model || CHAT_DEFAULT_MODEL,
|
|
228
|
+
logger: @logger
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def chat_transport_type
|
|
233
|
+
:openai_compatible
|
|
234
|
+
end
|
|
235
|
+
|
|
197
236
|
def auth_type
|
|
198
237
|
:oauth
|
|
199
238
|
end
|
|
@@ -801,6 +840,28 @@ module AgentHarness
|
|
|
801
840
|
def hash_key_present?(value, key)
|
|
802
841
|
value.is_a?(Hash) && value.key?(key)
|
|
803
842
|
end
|
|
843
|
+
|
|
844
|
+
def resolve_chat_api_key
|
|
845
|
+
key = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"] || read_copilot_cli_access_token
|
|
846
|
+
|
|
847
|
+
if key.nil? || key.strip.empty?
|
|
848
|
+
raise AuthenticationError.new(
|
|
849
|
+
"Chat mode requires a GitHub token. Set GITHUB_TOKEN or GH_TOKEN, or authenticate the Copilot CLI.",
|
|
850
|
+
provider: :github_copilot
|
|
851
|
+
)
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
key.strip
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def read_copilot_cli_access_token
|
|
858
|
+
path = Pathname.new(File.join(Dir.home, ".copilot-cli-access-token"))
|
|
859
|
+
return nil unless path.file?
|
|
860
|
+
|
|
861
|
+
path.read
|
|
862
|
+
rescue Errno::ENOENT, Errno::EACCES, IOError
|
|
863
|
+
nil
|
|
864
|
+
end
|
|
804
865
|
end
|
|
805
866
|
end
|
|
806
867
|
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentHarness
|
|
4
|
+
# Canonical provider-agnostic sub-agent definition.
|
|
5
|
+
class SubAgentConfig
|
|
6
|
+
attr_reader :name, :description, :instructions, :model, :tools, :mcp_servers
|
|
7
|
+
attr_reader :constraints, :handoff_conditions, :type, :sub_agents, :routing
|
|
8
|
+
|
|
9
|
+
def initialize(name:, description:, instructions:, model: "default", tools: [],
|
|
10
|
+
mcp_servers: [], constraints: {}, handoff_conditions: [], type: nil,
|
|
11
|
+
sub_agents: [], routing: nil)
|
|
12
|
+
@name = normalize_name(name)
|
|
13
|
+
@description = validate_string!(:description, description)
|
|
14
|
+
@instructions = validate_string!(:instructions, instructions)
|
|
15
|
+
@model = normalize_model(model)
|
|
16
|
+
@tools = normalize_array(:tools, tools)
|
|
17
|
+
@mcp_servers = normalize_array(:mcp_servers, mcp_servers)
|
|
18
|
+
@constraints = normalize_hash(:constraints, constraints)
|
|
19
|
+
@handoff_conditions = normalize_array(:handoff_conditions, handoff_conditions)
|
|
20
|
+
@type = type&.to_sym
|
|
21
|
+
@sub_agents = normalize_array(:sub_agents, sub_agents)
|
|
22
|
+
@routing = routing.nil? ? nil : normalize_hash(:routing, routing)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_hash(hash)
|
|
26
|
+
unless hash.is_a?(Hash)
|
|
27
|
+
raise ConfigurationError, "Sub-agent definition must be a Hash, got #{hash.class}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attrs = hash.each_with_object({}) do |(key, value), memo|
|
|
31
|
+
memo[key.to_sym] = value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
%i[name description instructions].each do |field|
|
|
35
|
+
value = attrs[field]
|
|
36
|
+
next if value.is_a?(String) && !value.strip.empty?
|
|
37
|
+
next if value.is_a?(Symbol)
|
|
38
|
+
|
|
39
|
+
raise ConfigurationError, "#{field} is required"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
new(**attrs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_h
|
|
46
|
+
{
|
|
47
|
+
name: @name,
|
|
48
|
+
description: @description,
|
|
49
|
+
instructions: @instructions,
|
|
50
|
+
model: @model,
|
|
51
|
+
tools: deep_dup(@tools),
|
|
52
|
+
mcp_servers: deep_dup(@mcp_servers),
|
|
53
|
+
constraints: deep_dup(@constraints),
|
|
54
|
+
handoff_conditions: deep_dup(@handoff_conditions),
|
|
55
|
+
type: @type,
|
|
56
|
+
sub_agents: deep_dup(@sub_agents),
|
|
57
|
+
routing: deep_dup(@routing)
|
|
58
|
+
}.compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def normalize_name(name)
|
|
64
|
+
value = validate_string!(:name, name)
|
|
65
|
+
value.tr(" ", "_").to_sym
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def normalize_model(model)
|
|
69
|
+
return "default" if model.nil?
|
|
70
|
+
|
|
71
|
+
validate_string!(:model, model)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_string!(field, value)
|
|
75
|
+
unless value.is_a?(String) || value.is_a?(Symbol)
|
|
76
|
+
raise ConfigurationError, "#{field} must be a String or Symbol"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
string = value.to_s.strip
|
|
80
|
+
raise ConfigurationError, "#{field} is required" if string.empty?
|
|
81
|
+
|
|
82
|
+
string
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalize_array(field, value)
|
|
86
|
+
return [].freeze if value.nil?
|
|
87
|
+
|
|
88
|
+
unless value.is_a?(Array)
|
|
89
|
+
raise ConfigurationError, "#{field} must be an Array"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
deep_dup(value).freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize_hash(field, value)
|
|
96
|
+
return {}.freeze if value.nil?
|
|
97
|
+
|
|
98
|
+
unless value.is_a?(Hash)
|
|
99
|
+
raise ConfigurationError, "#{field} must be a Hash"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
deep_dup(value).freeze
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def deep_dup(value)
|
|
106
|
+
case value
|
|
107
|
+
when Array
|
|
108
|
+
value.map { |entry| deep_dup(entry) }
|
|
109
|
+
when Hash
|
|
110
|
+
value.each_with_object({}) { |(key, entry), copy| copy[key] = deep_dup(entry) }
|
|
111
|
+
else
|
|
112
|
+
value.dup
|
|
113
|
+
end
|
|
114
|
+
rescue TypeError
|
|
115
|
+
value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module AgentHarness
|
|
6
|
+
# Loads canonical sub-agent definitions from YAML or Markdown files.
|
|
7
|
+
class SubAgentFileLoader
|
|
8
|
+
class << self
|
|
9
|
+
def load(path)
|
|
10
|
+
case File.extname(path).downcase
|
|
11
|
+
when ".yml", ".yaml"
|
|
12
|
+
load_yaml(path)
|
|
13
|
+
when ".md", ".markdown"
|
|
14
|
+
[load_markdown(path)]
|
|
15
|
+
else
|
|
16
|
+
raise ConfigurationError, "Unsupported sub-agent definition format: #{path}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def load_yaml(path)
|
|
23
|
+
parsed = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
24
|
+
unless parsed.is_a?(Hash)
|
|
25
|
+
raise ConfigurationError, "YAML sub-agent definition must be a Hash"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
agents = parsed["agents"] || parsed[:agents] || [parsed]
|
|
29
|
+
unless agents.is_a?(Array)
|
|
30
|
+
raise ConfigurationError, "YAML sub-agent definitions must provide an agents Array"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
agents.map { |entry| SubAgentConfig.from_hash(entry) }
|
|
34
|
+
rescue Psych::SyntaxError => e
|
|
35
|
+
raise ConfigurationError, "Invalid YAML in #{path}: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def load_markdown(path)
|
|
39
|
+
content = File.read(path)
|
|
40
|
+
match = content.match(/\A---\s*\n(?<frontmatter>.*?)\n---\s*\n?(?<body>.*)\z/m)
|
|
41
|
+
raise ConfigurationError, "Markdown sub-agent definitions require YAML frontmatter" unless match
|
|
42
|
+
|
|
43
|
+
attrs = YAML.safe_load(match[:frontmatter], permitted_classes: [], aliases: false) || {}
|
|
44
|
+
unless attrs.is_a?(Hash)
|
|
45
|
+
raise ConfigurationError, "Markdown frontmatter must be a Hash"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attrs["instructions"] ||= match[:body].strip
|
|
49
|
+
SubAgentConfig.from_hash(attrs)
|
|
50
|
+
rescue Psych::SyntaxError => e
|
|
51
|
+
raise ConfigurationError, "Invalid frontmatter in #{path}: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module AgentHarness
|
|
6
|
+
# Translates canonical sub-agent definitions into provider-specific formats.
|
|
7
|
+
module SubAgentTranslator
|
|
8
|
+
class << self
|
|
9
|
+
def for_provider(provider, sub_agent_config, tool_registry: AgentHarness.configuration.tool_registry,
|
|
10
|
+
mcp_servers: AgentHarness.configuration.mcp_servers)
|
|
11
|
+
config = normalize_sub_agent_config(sub_agent_config)
|
|
12
|
+
normalized_provider = normalize_provider(provider)
|
|
13
|
+
tools = resolve_tools(config.tools, provider: normalized_provider, tool_registry: tool_registry)
|
|
14
|
+
servers = resolve_mcp_servers(config.mcp_servers, mcp_servers: mcp_servers)
|
|
15
|
+
|
|
16
|
+
case normalized_provider
|
|
17
|
+
when :anthropic
|
|
18
|
+
translate_for_anthropic(config, tools: tools, mcp_servers: servers)
|
|
19
|
+
when :openai
|
|
20
|
+
translate_for_openai(config, tools: tools, mcp_servers: servers)
|
|
21
|
+
when :google
|
|
22
|
+
translate_for_google(config, tools: tools, mcp_servers: servers)
|
|
23
|
+
when :claude_code
|
|
24
|
+
translate_for_claude_code(config, tools: tools, mcp_servers: servers)
|
|
25
|
+
when :codex
|
|
26
|
+
translate_for_codex(config, tools: tools, mcp_servers: servers)
|
|
27
|
+
when :pi
|
|
28
|
+
translate_for_pi(config, tools: tools, mcp_servers: servers)
|
|
29
|
+
else
|
|
30
|
+
translate_generic(normalized_provider, config, tools: tools, mcp_servers: servers)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def normalize_sub_agent_config(sub_agent_config)
|
|
37
|
+
case sub_agent_config
|
|
38
|
+
when SubAgentConfig
|
|
39
|
+
sub_agent_config
|
|
40
|
+
when Hash
|
|
41
|
+
SubAgentConfig.from_hash(sub_agent_config)
|
|
42
|
+
else
|
|
43
|
+
raise ConfigurationError, "Sub-agent config must be a SubAgentConfig or Hash"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_provider(provider)
|
|
48
|
+
case provider.to_sym
|
|
49
|
+
when :claude then :anthropic
|
|
50
|
+
when :gemini then :google
|
|
51
|
+
else provider.to_sym
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def resolve_tools(tool_refs, provider:, tool_registry:)
|
|
56
|
+
tool_refs.map do |tool|
|
|
57
|
+
case tool
|
|
58
|
+
when Symbol, String
|
|
59
|
+
mapping = tool_registry.fetch(tool).mapping_for(provider)
|
|
60
|
+
mapping.nil? ? tool.to_s : mapping
|
|
61
|
+
when Hash
|
|
62
|
+
deep_dup(tool)
|
|
63
|
+
else
|
|
64
|
+
raise ConfigurationError, "Unsupported tool reference #{tool.inspect} in sub-agent definition"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_mcp_servers(server_refs, mcp_servers:)
|
|
70
|
+
server_refs.map do |server|
|
|
71
|
+
resolved = case server
|
|
72
|
+
when McpServer
|
|
73
|
+
server
|
|
74
|
+
when Symbol, String
|
|
75
|
+
mcp_servers.fetch(server.to_sym) do
|
|
76
|
+
raise ConfigurationError, "Unknown MCP server: #{server}"
|
|
77
|
+
end
|
|
78
|
+
when Hash
|
|
79
|
+
McpServer.from_hash(server)
|
|
80
|
+
else
|
|
81
|
+
raise ConfigurationError, "Unsupported MCP server reference #{server.inspect} in sub-agent definition"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
resolved.to_h
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def translate_for_anthropic(config, tools:, mcp_servers:)
|
|
89
|
+
{
|
|
90
|
+
provider: :anthropic,
|
|
91
|
+
format: :agent_sdk,
|
|
92
|
+
agent: {
|
|
93
|
+
name: config.name.to_s,
|
|
94
|
+
description: config.description,
|
|
95
|
+
instructions: config.instructions,
|
|
96
|
+
model: config.model,
|
|
97
|
+
tools: tools,
|
|
98
|
+
mcp_servers: mcp_servers,
|
|
99
|
+
constraints: deep_dup(config.constraints)
|
|
100
|
+
},
|
|
101
|
+
runtime_instructions: runtime_instructions(config)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def translate_for_openai(config, tools:, mcp_servers:)
|
|
106
|
+
{
|
|
107
|
+
provider: :openai,
|
|
108
|
+
format: :responses,
|
|
109
|
+
assistant: {
|
|
110
|
+
name: config.name.to_s,
|
|
111
|
+
description: config.description,
|
|
112
|
+
instructions: config.instructions,
|
|
113
|
+
model: config.model,
|
|
114
|
+
tools: tools,
|
|
115
|
+
metadata: {
|
|
116
|
+
mcp_servers: mcp_servers,
|
|
117
|
+
constraints: deep_dup(config.constraints)
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
runtime_instructions: runtime_instructions(config)
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def translate_for_google(config, tools:, mcp_servers:)
|
|
125
|
+
{
|
|
126
|
+
provider: :google,
|
|
127
|
+
format: :adk,
|
|
128
|
+
agent: {
|
|
129
|
+
name: config.name.to_s,
|
|
130
|
+
description: config.description,
|
|
131
|
+
instruction: config.instructions,
|
|
132
|
+
model: config.model,
|
|
133
|
+
tools: tools,
|
|
134
|
+
mcp_servers: mcp_servers,
|
|
135
|
+
constraints: deep_dup(config.constraints)
|
|
136
|
+
},
|
|
137
|
+
runtime_instructions: runtime_instructions(config)
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def translate_for_claude_code(config, tools:, mcp_servers:)
|
|
142
|
+
frontmatter = {
|
|
143
|
+
"name" => config.name.to_s,
|
|
144
|
+
"description" => config.description,
|
|
145
|
+
"model" => config.model,
|
|
146
|
+
"tools" => tools,
|
|
147
|
+
"mcp_servers" => mcp_servers,
|
|
148
|
+
"constraints" => deep_dup(config.constraints)
|
|
149
|
+
}.delete_if { |_key, value| value.respond_to?(:empty?) ? value.empty? : value.nil? }
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
provider: :claude_code,
|
|
153
|
+
format: :markdown,
|
|
154
|
+
content: build_markdown_definition(frontmatter, config.instructions),
|
|
155
|
+
runtime_instructions: runtime_instructions(config)
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def translate_for_codex(config, tools:, mcp_servers:)
|
|
160
|
+
{
|
|
161
|
+
provider: :codex,
|
|
162
|
+
format: :delegated_prompt,
|
|
163
|
+
definition: {
|
|
164
|
+
name: config.name.to_s,
|
|
165
|
+
description: config.description,
|
|
166
|
+
instructions: config.instructions,
|
|
167
|
+
model: config.model,
|
|
168
|
+
tools: tools,
|
|
169
|
+
mcp_servers: mcp_servers,
|
|
170
|
+
constraints: deep_dup(config.constraints)
|
|
171
|
+
},
|
|
172
|
+
runtime_instructions: runtime_instructions(config)
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def translate_for_pi(config, tools:, mcp_servers:)
|
|
177
|
+
frontmatter = {
|
|
178
|
+
"name" => config.name.to_s,
|
|
179
|
+
"description" => config.description,
|
|
180
|
+
"model" => config.model,
|
|
181
|
+
"tools" => tools,
|
|
182
|
+
"mcp_servers" => mcp_servers
|
|
183
|
+
}.delete_if { |_key, value| value.respond_to?(:empty?) ? value.empty? : value.nil? }
|
|
184
|
+
|
|
185
|
+
{
|
|
186
|
+
provider: :pi,
|
|
187
|
+
format: :skill_markdown,
|
|
188
|
+
content: build_markdown_definition(frontmatter, config.instructions),
|
|
189
|
+
runtime_instructions: runtime_instructions(config)
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def translate_generic(provider, config, tools:, mcp_servers:)
|
|
194
|
+
{
|
|
195
|
+
provider: provider,
|
|
196
|
+
format: :generic,
|
|
197
|
+
definition: {
|
|
198
|
+
name: config.name.to_s,
|
|
199
|
+
description: config.description,
|
|
200
|
+
instructions: config.instructions,
|
|
201
|
+
model: config.model,
|
|
202
|
+
tools: tools,
|
|
203
|
+
mcp_servers: mcp_servers,
|
|
204
|
+
constraints: deep_dup(config.constraints)
|
|
205
|
+
},
|
|
206
|
+
runtime_instructions: runtime_instructions(config)
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def runtime_instructions(config)
|
|
211
|
+
<<~TEXT.strip
|
|
212
|
+
Sub-agent role: #{config.name}
|
|
213
|
+
Description: #{config.description}
|
|
214
|
+
|
|
215
|
+
Follow these sub-agent instructions exactly:
|
|
216
|
+
#{config.instructions}
|
|
217
|
+
TEXT
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def build_markdown_definition(frontmatter, instructions)
|
|
221
|
+
<<~MARKDOWN
|
|
222
|
+
---
|
|
223
|
+
#{YAML.dump(frontmatter).sub(/\A---\s*\n/, "").strip}
|
|
224
|
+
---
|
|
225
|
+
#{instructions}
|
|
226
|
+
MARKDOWN
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def deep_dup(value)
|
|
230
|
+
case value
|
|
231
|
+
when Array
|
|
232
|
+
value.map { |entry| deep_dup(entry) }
|
|
233
|
+
when Hash
|
|
234
|
+
value.each_with_object({}) { |(key, entry), copy| copy[key] = deep_dup(entry) }
|
|
235
|
+
else
|
|
236
|
+
value.dup
|
|
237
|
+
end
|
|
238
|
+
rescue TypeError
|
|
239
|
+
value
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|