language-operator 0.1.58 → 0.1.61

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/components/agent/Gemfile +1 -1
  4. data/lib/language_operator/agent/base.rb +22 -0
  5. data/lib/language_operator/agent/task_executor.rb +80 -23
  6. data/lib/language_operator/agent/telemetry.rb +22 -11
  7. data/lib/language_operator/agent.rb +3 -0
  8. data/lib/language_operator/cli/base_command.rb +7 -1
  9. data/lib/language_operator/cli/commands/agent.rb +575 -0
  10. data/lib/language_operator/cli/formatters/optimization_formatter.rb +226 -0
  11. data/lib/language_operator/cli/formatters/progress_formatter.rb +1 -1
  12. data/lib/language_operator/client/base.rb +74 -2
  13. data/lib/language_operator/client/mcp_connector.rb +4 -6
  14. data/lib/language_operator/dsl/task_definition.rb +7 -6
  15. data/lib/language_operator/learning/adapters/base_adapter.rb +149 -0
  16. data/lib/language_operator/learning/adapters/jaeger_adapter.rb +221 -0
  17. data/lib/language_operator/learning/adapters/signoz_adapter.rb +435 -0
  18. data/lib/language_operator/learning/adapters/tempo_adapter.rb +239 -0
  19. data/lib/language_operator/learning/optimizer.rb +319 -0
  20. data/lib/language_operator/learning/pattern_detector.rb +260 -0
  21. data/lib/language_operator/learning/task_synthesizer.rb +288 -0
  22. data/lib/language_operator/learning/trace_analyzer.rb +285 -0
  23. data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
  24. data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
  25. data/lib/language_operator/templates/task_synthesis.tmpl +98 -0
  26. data/lib/language_operator/ux/concerns/provider_helpers.rb +2 -2
  27. data/lib/language_operator/version.rb +1 -1
  28. data/synth/003/Makefile +10 -3
  29. data/synth/003/output.log +68 -0
  30. data/synth/README.md +1 -3
  31. metadata +12 -1
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require_relative 'base_adapter'
7
+
8
+ module LanguageOperator
9
+ module Learning
10
+ module Adapters
11
+ # Grafana Tempo backend adapter for trace queries
12
+ #
13
+ # Queries Tempo's Parquet-backed trace storage via the /api/search
14
+ # HTTP endpoint with TraceQL query language support.
15
+ #
16
+ # TraceQL provides powerful span filtering with structural operators:
17
+ # - { span.attribute = "value" } - Basic attribute filtering
18
+ # - { span.foo = "bar" && span.baz > 100 } - Multiple conditions
19
+ # - { span.parent } >> { span.child } - Structural relationships
20
+ #
21
+ # @example Basic usage
22
+ # adapter = TempoAdapter.new('http://tempo:3200')
23
+ #
24
+ # spans = adapter.query_spans(
25
+ # filter: { task_name: 'fetch_data' },
26
+ # time_range: (Time.now - 3600)..Time.now,
27
+ # limit: 100
28
+ # )
29
+ class TempoAdapter < BaseAdapter
30
+ # Tempo search endpoint
31
+ SEARCH_PATH = '/api/search'
32
+
33
+ # Check if Tempo is available at endpoint
34
+ #
35
+ # @param endpoint [String] Tempo endpoint URL
36
+ # @param _api_key [String, nil] API key (unused, Tempo typically doesn't require auth)
37
+ # @return [Boolean] True if Tempo API is reachable
38
+ def self.available?(endpoint, _api_key = nil)
39
+ uri = URI.join(endpoint, SEARCH_PATH)
40
+ # Test with minimal query
41
+ uri.query = URI.encode_www_form(q: '{ }', limit: 1)
42
+
43
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 2, read_timeout: 2) do |http|
44
+ request = Net::HTTP::Get.new(uri.request_uri)
45
+ http.request(request)
46
+ end
47
+
48
+ response.is_a?(Net::HTTPSuccess)
49
+ rescue StandardError
50
+ false
51
+ end
52
+
53
+ # Query spans from Tempo using TraceQL
54
+ #
55
+ # @param filter [Hash] Filter criteria
56
+ # @option filter [String] :task_name Task name to filter by
57
+ # @param time_range [Range<Time>] Time range for query
58
+ # @param limit [Integer] Maximum traces to return
59
+ # @return [Array<Hash>] Normalized span data
60
+ def query_spans(filter:, time_range:, limit:)
61
+ times = parse_time_range(time_range)
62
+ traceql_query = build_traceql_query(filter)
63
+ traces = search_traces(traceql_query, times, limit)
64
+ extract_spans_from_traces(traces)
65
+ end
66
+
67
+ private
68
+
69
+ # Build TraceQL query from filter
70
+ #
71
+ # @param filter [Hash] Filter criteria
72
+ # @return [String] TraceQL query string
73
+ def build_traceql_query(filter)
74
+ conditions = []
75
+
76
+ # Filter by task name
77
+ conditions << "span.\"task.name\" = \"#{escape_traceql_value(filter[:task_name])}\"" if filter[:task_name]
78
+
79
+ # Filter by agent name
80
+ conditions << "span.\"agent.name\" = \"#{escape_traceql_value(filter[:agent_name])}\"" if filter[:agent_name]
81
+
82
+ # Additional attribute filters
83
+ if filter[:attributes].is_a?(Hash)
84
+ filter[:attributes].each do |key, value|
85
+ conditions << "span.\"#{escape_traceql_key(key)}\" = \"#{escape_traceql_value(value)}\""
86
+ end
87
+ end
88
+
89
+ # Combine conditions with AND
90
+ query = conditions.any? ? conditions.join(' && ') : ''
91
+ "{ #{query} }"
92
+ end
93
+
94
+ # Escape TraceQL attribute key
95
+ #
96
+ # @param key [String, Symbol] Attribute key
97
+ # @return [String] Escaped key
98
+ def escape_traceql_key(key)
99
+ key.to_s.gsub('"', '\"')
100
+ end
101
+
102
+ # Escape TraceQL value
103
+ #
104
+ # @param value [Object] Attribute value
105
+ # @return [String] Escaped value
106
+ def escape_traceql_value(value)
107
+ value.to_s.gsub('"', '\"').gsub('\\', '\\\\')
108
+ end
109
+
110
+ # Search traces via Tempo HTTP API
111
+ #
112
+ # @param traceql_query [String] TraceQL query
113
+ # @param times [Hash] Start and end times
114
+ # @param limit [Integer] Result limit
115
+ # @return [Array<Hash>] Trace data
116
+ def search_traces(traceql_query, times, limit)
117
+ uri = build_search_uri(traceql_query, times, limit)
118
+
119
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 30) do |http|
120
+ request = Net::HTTP::Get.new(uri.request_uri)
121
+ request['Accept'] = 'application/json'
122
+
123
+ response = http.request(request)
124
+
125
+ raise "Tempo query failed: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
126
+
127
+ result = JSON.parse(response.body, symbolize_names: true)
128
+ result[:traces] || []
129
+ end
130
+ end
131
+
132
+ # Build Tempo search URI with query parameters
133
+ #
134
+ # @param traceql_query [String] TraceQL query
135
+ # @param times [Hash] Start and end times
136
+ # @param limit [Integer] Result limit
137
+ # @return [URI] Complete URI with query params
138
+ def build_search_uri(traceql_query, times, limit)
139
+ params = {
140
+ q: traceql_query,
141
+ limit: limit,
142
+ start: times[:start].to_i, # Unix seconds
143
+ end: times[:end].to_i
144
+ }
145
+
146
+ uri = URI.join(@endpoint, SEARCH_PATH)
147
+ uri.query = URI.encode_www_form(params)
148
+ uri
149
+ end
150
+
151
+ # Extract all spans from traces
152
+ #
153
+ # @param traces [Array<Hash>] Tempo trace data
154
+ # @return [Array<Hash>] Normalized spans
155
+ def extract_spans_from_traces(traces)
156
+ spans = []
157
+
158
+ traces.each do |trace|
159
+ trace_id = trace[:traceID]
160
+
161
+ # Tempo returns spanSets (matched span groups)
162
+ (trace[:spanSets] || []).each do |span_set|
163
+ (span_set[:spans] || []).each do |span_data|
164
+ spans << normalize_span(span_data, trace_id)
165
+ end
166
+ end
167
+ end
168
+
169
+ spans
170
+ end
171
+
172
+ # Normalize Tempo span to common format
173
+ #
174
+ # @param span_data [Hash] Raw Tempo span
175
+ # @param trace_id [String] Trace ID
176
+ # @return [Hash] Normalized span
177
+ def normalize_span(span_data, trace_id)
178
+ {
179
+ span_id: span_data[:spanID],
180
+ trace_id: trace_id,
181
+ name: span_data[:name] || 'unknown',
182
+ timestamp: parse_timestamp(span_data[:startTimeUnixNano]),
183
+ duration_ms: parse_duration(span_data[:durationNanos]),
184
+ attributes: parse_attributes(span_data[:attributes])
185
+ }
186
+ end
187
+
188
+ # Parse Tempo timestamp (nanoseconds) to Time
189
+ #
190
+ # @param timestamp [String, Integer] Timestamp in nanoseconds
191
+ # @return [Time] Parsed time
192
+ def parse_timestamp(timestamp)
193
+ return Time.now unless timestamp
194
+
195
+ nanos = timestamp.is_a?(String) ? timestamp.to_i : timestamp
196
+ Time.at(nanos / 1_000_000_000.0)
197
+ end
198
+
199
+ # Parse Tempo duration (nanoseconds) to milliseconds
200
+ #
201
+ # @param duration [Integer] Duration in nanoseconds
202
+ # @return [Float] Duration in milliseconds
203
+ def parse_duration(duration)
204
+ return 0.0 unless duration
205
+
206
+ duration / 1_000_000.0
207
+ end
208
+
209
+ # Parse Tempo attributes into flat hash
210
+ #
211
+ # Tempo attributes format:
212
+ # [
213
+ # { key: "http.method", value: { stringValue: "GET" } },
214
+ # { key: "http.status_code", value: { intValue: 200 } }
215
+ # ]
216
+ #
217
+ # @param attributes [Array<Hash>] Attribute array
218
+ # @return [Hash] Flat attributes
219
+ def parse_attributes(attributes)
220
+ return {} unless attributes.is_a?(Array)
221
+
222
+ attributes.each_with_object({}) do |attr, hash|
223
+ key = attr[:key].to_s
224
+ value_obj = attr[:value] || {}
225
+
226
+ # Extract value based on type
227
+ value = value_obj[:stringValue] ||
228
+ value_obj[:intValue] ||
229
+ value_obj[:doubleValue] ||
230
+ value_obj[:boolValue] ||
231
+ value_obj[:bytesValue]
232
+
233
+ hash[key] = value if value
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module LanguageOperator
6
+ module Learning
7
+ # Orchestrates the optimization of neural tasks to symbolic implementations
8
+ #
9
+ # The Optimizer analyzes running agents, identifies optimization opportunities,
10
+ # proposes code changes, and applies them with user approval. It integrates
11
+ # TraceAnalyzer (pattern detection) and PatternDetector (code generation) to
12
+ # provide a complete optimization workflow.
13
+ #
14
+ # @example Basic usage
15
+ # optimizer = Optimizer.new(
16
+ # agent_name: 'github-monitor',
17
+ # agent_definition: agent_def,
18
+ # trace_analyzer: TraceAnalyzer.new(endpoint: ENV['OTEL_QUERY_ENDPOINT']),
19
+ # pattern_detector: PatternDetector.new(...)
20
+ # )
21
+ #
22
+ # opportunities = optimizer.analyze
23
+ # opportunities.each do |opp|
24
+ # proposal = optimizer.propose(task_name: opp[:task_name])
25
+ # # Show to user, get approval
26
+ # optimizer.apply(proposal) if approved
27
+ # end
28
+ class Optimizer
29
+ # Minimum consistency score required for optimization
30
+ DEFAULT_MIN_CONSISTENCY = 0.85
31
+
32
+ # Minimum executions required for optimization
33
+ DEFAULT_MIN_EXECUTIONS = 10
34
+
35
+ # Initialize optimizer
36
+ #
37
+ # @param agent_name [String] Name of the agent to optimize
38
+ # @param agent_definition [Dsl::AgentDefinition] Agent definition object
39
+ # @param trace_analyzer [TraceAnalyzer] Analyzer for querying execution traces
40
+ # @param pattern_detector [PatternDetector] Detector for generating symbolic code
41
+ # @param task_synthesizer [TaskSynthesizer, nil] Optional LLM-based synthesizer
42
+ # @param logger [Logger, nil] Logger instance (creates default if nil)
43
+ def initialize(agent_name:, agent_definition:, trace_analyzer:, pattern_detector:, task_synthesizer: nil,
44
+ logger: nil)
45
+ @agent_name = agent_name
46
+ @agent_definition = agent_definition
47
+ @trace_analyzer = trace_analyzer
48
+ @pattern_detector = pattern_detector
49
+ @task_synthesizer = task_synthesizer
50
+ @logger = logger || ::Logger.new($stdout, level: ::Logger::WARN)
51
+ end
52
+
53
+ # Analyze agent for optimization opportunities
54
+ #
55
+ # Queries execution traces for each neural task and determines which tasks
56
+ # are eligible for optimization based on consistency and execution count.
57
+ #
58
+ # @param min_consistency [Float] Minimum consistency threshold (0.0-1.0)
59
+ # @param min_executions [Integer] Minimum execution count required
60
+ # @param time_range [Integer, Range<Time>, nil] Time range for trace queries
61
+ # @return [Array<Hash>] Array of optimization opportunities
62
+ def analyze(min_consistency: DEFAULT_MIN_CONSISTENCY, min_executions: DEFAULT_MIN_EXECUTIONS, time_range: nil)
63
+ opportunities = []
64
+
65
+ # Find all neural tasks in the agent
66
+ neural_tasks = find_neural_tasks
67
+
68
+ if neural_tasks.empty?
69
+ @logger.info("No neural tasks found in agent '#{@agent_name}'")
70
+ return opportunities
71
+ end
72
+
73
+ # Analyze each neural task
74
+ neural_tasks.each do |task|
75
+ analysis = @trace_analyzer.analyze_patterns(
76
+ task_name: task[:name],
77
+ agent_name: @agent_name,
78
+ min_executions: min_executions,
79
+ consistency_threshold: min_consistency,
80
+ time_range: time_range
81
+ )
82
+
83
+ next unless analysis
84
+
85
+ opportunities << {
86
+ task_name: task[:name],
87
+ task_definition: task[:definition],
88
+ execution_count: analysis[:execution_count],
89
+ consistency_score: analysis[:consistency_score],
90
+ ready_for_learning: analysis[:ready_for_learning],
91
+ common_pattern: analysis[:common_pattern],
92
+ reason: analysis[:reason]
93
+ }
94
+ end
95
+
96
+ opportunities
97
+ end
98
+
99
+ # Generate optimization proposal for a specific task
100
+ #
101
+ # Uses PatternDetector to generate symbolic code and calculates
102
+ # the performance impact of the optimization. Falls back to TaskSynthesizer
103
+ # (LLM-based) if pattern detection fails and synthesizer is available.
104
+ #
105
+ # @param task_name [String] Name of task to optimize
106
+ # @param use_synthesis [Boolean] Force use of LLM synthesis instead of pattern detection
107
+ # @return [Hash] Optimization proposal with code, metrics, and metadata
108
+ def propose(task_name:, use_synthesis: false)
109
+ task_def = find_task_definition(task_name)
110
+ raise ArgumentError, "Task '#{task_name}' not found" unless task_def
111
+
112
+ analysis = @trace_analyzer.analyze_patterns(task_name: task_name, agent_name: @agent_name)
113
+ raise ArgumentError, "No execution data found for task '#{task_name}'" unless analysis
114
+
115
+ traces = @trace_analyzer.query_task_traces(task_name: task_name, agent_name: @agent_name, limit: 20)
116
+ detection_result = @pattern_detector.detect_pattern(analysis_result: analysis) unless use_synthesis
117
+
118
+ return propose_via_synthesis(task_name, task_def, analysis, traces) if should_use_synthesis?(use_synthesis, detection_result)
119
+
120
+ unless detection_result&.dig(:success)
121
+ raise ArgumentError, "Cannot optimize task '#{task_name}': #{detection_result&.dig(:reason) || 'No common pattern found'}"
122
+ end
123
+
124
+ build_pattern_proposal(task_name, task_def, analysis, detection_result)
125
+ end
126
+
127
+ # Apply optimization proposal
128
+ #
129
+ # This method would update the agent definition in Kubernetes.
130
+ # For now, it returns the updated agent code that would be applied.
131
+ #
132
+ # @param proposal [Hash] Proposal from #propose
133
+ # @return [Hash] Result with updated agent definition
134
+ def apply(proposal:)
135
+ # In a real implementation, this would:
136
+ # 1. Update the agent CRD with new task definition
137
+ # 2. Create new ConfigMap version
138
+ # 3. Trigger pod restart
139
+ # For now, we return what would be applied
140
+
141
+ {
142
+ success: true,
143
+ task_name: proposal[:task_name],
144
+ updated_code: proposal[:proposed_code],
145
+ action: 'would_update_agent_definition',
146
+ message: "Optimization for '#{proposal[:task_name]}' ready to apply"
147
+ }
148
+ end
149
+
150
+ private
151
+
152
+ def should_use_synthesis?(use_synthesis, detection_result)
153
+ (use_synthesis || !detection_result&.dig(:success)) && @task_synthesizer
154
+ end
155
+
156
+ def propose_via_synthesis(task_name, task_def, analysis, traces)
157
+ @logger.info("Using LLM synthesis for task '#{task_name}'")
158
+ synthesis_result = @task_synthesizer.synthesize(
159
+ task_definition: task_def,
160
+ traces: traces,
161
+ available_tools: detect_available_tools,
162
+ consistency_score: analysis[:consistency_score],
163
+ common_pattern: analysis[:common_pattern]
164
+ )
165
+
166
+ raise ArgumentError, "Cannot optimize task '#{task_name}': #{synthesis_result[:explanation]}" unless synthesis_result[:is_deterministic]
167
+
168
+ build_synthesis_proposal(task_name: task_name, task_def: task_def, analysis: analysis,
169
+ synthesis_result: synthesis_result)
170
+ end
171
+
172
+ def build_pattern_proposal(task_name, task_def, analysis, detection_result)
173
+ impact = calculate_impact(execution_count: analysis[:execution_count],
174
+ consistency_score: analysis[:consistency_score])
175
+ {
176
+ task_name: task_name, current_code: format_current_code(task_def),
177
+ proposed_code: extract_task_code(detection_result[:generated_code]),
178
+ full_generated_code: detection_result[:generated_code],
179
+ consistency_score: analysis[:consistency_score], execution_count: analysis[:execution_count],
180
+ pattern: analysis[:common_pattern], performance_impact: impact,
181
+ validation_violations: detection_result[:validation_violations],
182
+ ready_to_deploy: detection_result[:ready_to_deploy], synthesis_method: :pattern_detection
183
+ }
184
+ end
185
+
186
+ # Find all neural tasks in the agent definition
187
+ #
188
+ # @return [Array<Hash>] Array of neural task info
189
+ def find_neural_tasks
190
+ return [] unless @agent_definition.respond_to?(:tasks)
191
+
192
+ neural_tasks = @agent_definition.tasks.select do |_name, task_def|
193
+ # Neural tasks have instructions but no code block
194
+ task_def.neural?
195
+ end
196
+
197
+ neural_tasks.map do |name, task_def|
198
+ {
199
+ name: name.to_s,
200
+ definition: task_def
201
+ }
202
+ end
203
+ end
204
+
205
+ # Find a specific task definition by name
206
+ #
207
+ # @param task_name [String] Task name
208
+ # @return [Dsl::TaskDefinition, nil] Task definition or nil
209
+ def find_task_definition(task_name)
210
+ return nil unless @agent_definition.respond_to?(:tasks)
211
+
212
+ @agent_definition.tasks[task_name.to_sym]
213
+ end
214
+
215
+ # Format current task code for display
216
+ #
217
+ # @param task_def [Dsl::TaskDefinition] Task definition
218
+ # @return [String] Formatted code
219
+ def format_current_code(task_def)
220
+ inputs_str = (task_def.inputs || {}).map { |k, v| "#{k}: '#{v}'" }.join(', ')
221
+ outputs_str = (task_def.outputs || {}).map { |k, v| "#{k}: '#{v}'" }.join(', ')
222
+
223
+ <<~RUBY
224
+ task :#{task_def.name},
225
+ instructions: "#{task_def.instructions}",
226
+ inputs: { #{inputs_str} },
227
+ outputs: { #{outputs_str} }
228
+ RUBY
229
+ end
230
+
231
+ # Extract task code from full agent definition
232
+ #
233
+ # @param full_code [String] Complete agent definition
234
+ # @return [String] Just the task definition portion
235
+ def extract_task_code(full_code)
236
+ # Extract just the task definition from the full agent code
237
+ lines = full_code.lines
238
+ task_start = lines.index { |l| l.strip.start_with?('task :') }
239
+ task_end = lines.index { |l| l.strip == 'end' && l.start_with?(' end') }
240
+
241
+ return full_code unless task_start && task_end
242
+
243
+ lines[task_start..task_end].join
244
+ end
245
+
246
+ # Calculate performance impact of optimization
247
+ #
248
+ # @param execution_count [Integer] Number of executions observed
249
+ # @param consistency_score [Float] Pattern consistency
250
+ # @return [Hash] Impact metrics
251
+ def calculate_impact(execution_count:, consistency_score:)
252
+ # Estimates based on typical LLM vs symbolic execution
253
+ avg_neural_time = 2.5 # seconds
254
+ avg_neural_cost = 0.003 # dollars
255
+ avg_symbolic_time = 0.1 # seconds
256
+ avg_symbolic_cost = 0.0 # dollars
257
+
258
+ time_saved = avg_neural_time - avg_symbolic_time
259
+ cost_saved = avg_neural_cost - avg_symbolic_cost
260
+
261
+ {
262
+ current_avg_time: avg_neural_time,
263
+ optimized_avg_time: avg_symbolic_time,
264
+ time_reduction_pct: ((time_saved / avg_neural_time) * 100).round(1),
265
+ current_avg_cost: avg_neural_cost,
266
+ optimized_avg_cost: avg_symbolic_cost,
267
+ cost_reduction_pct: ((cost_saved / avg_neural_cost) * 100).round(1),
268
+ projected_monthly_savings: (cost_saved * execution_count * 30).round(2)
269
+ }
270
+ end
271
+
272
+ # Build proposal from synthesis result
273
+ #
274
+ # @param task_name [String] Task name
275
+ # @param task_def [Dsl::TaskDefinition] Task definition
276
+ # @param analysis [Hash] Pattern analysis result
277
+ # @param synthesis_result [Hash] LLM synthesis result
278
+ # @return [Hash] Optimization proposal
279
+ def build_synthesis_proposal(task_name:, task_def:, analysis:, synthesis_result:)
280
+ impact = calculate_impact(
281
+ execution_count: analysis[:execution_count],
282
+ consistency_score: synthesis_result[:confidence]
283
+ )
284
+
285
+ {
286
+ task_name: task_name,
287
+ current_code: format_current_code(task_def),
288
+ proposed_code: synthesis_result[:code],
289
+ full_generated_code: synthesis_result[:code],
290
+ consistency_score: analysis[:consistency_score],
291
+ execution_count: analysis[:execution_count],
292
+ pattern: analysis[:common_pattern],
293
+ performance_impact: impact,
294
+ validation_violations: synthesis_result[:validation_errors] || [],
295
+ ready_to_deploy: synthesis_result[:validation_errors].nil?,
296
+ synthesis_method: :llm_synthesis,
297
+ synthesis_confidence: synthesis_result[:confidence],
298
+ synthesis_explanation: synthesis_result[:explanation]
299
+ }
300
+ end
301
+
302
+ # Detect available tools from agent definition
303
+ #
304
+ # @return [Array<String>] Tool names
305
+ def detect_available_tools
306
+ return [] unless @agent_definition.respond_to?(:mcp_servers)
307
+
308
+ # Extract tool names from MCP server configurations
309
+ tools = []
310
+ @agent_definition.mcp_servers.each_value do |server|
311
+ tools.concat(server[:tools] || []) if server.is_a?(Hash)
312
+ end
313
+ tools.uniq
314
+ rescue StandardError
315
+ []
316
+ end
317
+ end
318
+ end
319
+ end