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.
@@ -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