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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/components/agent/Gemfile +1 -1
- data/lib/language_operator/agent/base.rb +22 -0
- data/lib/language_operator/agent/task_executor.rb +80 -23
- data/lib/language_operator/agent/telemetry.rb +22 -11
- data/lib/language_operator/agent.rb +3 -0
- data/lib/language_operator/cli/base_command.rb +7 -1
- data/lib/language_operator/cli/commands/agent.rb +575 -0
- data/lib/language_operator/cli/formatters/optimization_formatter.rb +226 -0
- data/lib/language_operator/cli/formatters/progress_formatter.rb +1 -1
- data/lib/language_operator/client/base.rb +74 -2
- data/lib/language_operator/client/mcp_connector.rb +4 -6
- data/lib/language_operator/dsl/task_definition.rb +7 -6
- data/lib/language_operator/learning/adapters/base_adapter.rb +149 -0
- data/lib/language_operator/learning/adapters/jaeger_adapter.rb +221 -0
- data/lib/language_operator/learning/adapters/signoz_adapter.rb +435 -0
- data/lib/language_operator/learning/adapters/tempo_adapter.rb +239 -0
- data/lib/language_operator/learning/optimizer.rb +319 -0
- data/lib/language_operator/learning/pattern_detector.rb +260 -0
- data/lib/language_operator/learning/task_synthesizer.rb +288 -0
- data/lib/language_operator/learning/trace_analyzer.rb +285 -0
- data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
- data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
- data/lib/language_operator/templates/task_synthesis.tmpl +98 -0
- data/lib/language_operator/ux/concerns/provider_helpers.rb +2 -2
- data/lib/language_operator/version.rb +1 -1
- data/synth/003/Makefile +10 -3
- data/synth/003/output.log +68 -0
- data/synth/README.md +1 -3
- metadata +12 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pastel'
|
|
4
|
+
|
|
5
|
+
module LanguageOperator
|
|
6
|
+
module CLI
|
|
7
|
+
module Formatters
|
|
8
|
+
# Formats optimization analysis and proposals for CLI display
|
|
9
|
+
class OptimizationFormatter
|
|
10
|
+
def initialize
|
|
11
|
+
@pastel = Pastel.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Format analysis results showing optimization opportunities
|
|
15
|
+
#
|
|
16
|
+
# @param agent_name [String] Name of the agent
|
|
17
|
+
# @param opportunities [Array<Hash>] Optimization opportunities
|
|
18
|
+
# @return [String] Formatted output
|
|
19
|
+
def format_analysis(agent_name:, opportunities:)
|
|
20
|
+
output = []
|
|
21
|
+
output << ''
|
|
22
|
+
output << @pastel.bold("Analyzing agent '#{agent_name}'...")
|
|
23
|
+
output << @pastel.dim('─' * 70)
|
|
24
|
+
output << ''
|
|
25
|
+
|
|
26
|
+
if opportunities.empty?
|
|
27
|
+
output << @pastel.yellow('No optimization opportunities found.')
|
|
28
|
+
output << ''
|
|
29
|
+
output << 'Possible reasons:'
|
|
30
|
+
output << ' • All tasks are already symbolic'
|
|
31
|
+
output << " • Neural tasks haven't executed enough times (need 10+)"
|
|
32
|
+
output << ' • Execution patterns are too inconsistent (<85%)'
|
|
33
|
+
output << ''
|
|
34
|
+
return output.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Group by status
|
|
38
|
+
ready = opportunities.select { |opp| opp[:ready_for_learning] }
|
|
39
|
+
not_ready = opportunities.reject { |opp| opp[:ready_for_learning] }
|
|
40
|
+
|
|
41
|
+
output << @pastel.bold("Found #{opportunities.size} neural task(s)\n")
|
|
42
|
+
|
|
43
|
+
# Show ready tasks
|
|
44
|
+
if ready.any?
|
|
45
|
+
output << @pastel.green.bold("✓ Ready for Optimization (#{ready.size})")
|
|
46
|
+
output << ''
|
|
47
|
+
ready.each do |opp|
|
|
48
|
+
output << format_opportunity(opp, ready: true)
|
|
49
|
+
output << ''
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Show not ready tasks
|
|
54
|
+
if not_ready.any?
|
|
55
|
+
output << @pastel.yellow.bold("⚠ Not Ready (#{not_ready.size})")
|
|
56
|
+
output << ''
|
|
57
|
+
not_ready.each do |opp|
|
|
58
|
+
output << format_opportunity(opp, ready: false)
|
|
59
|
+
output << ''
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Summary
|
|
64
|
+
output << @pastel.dim('─' * 70)
|
|
65
|
+
output << if ready.any?
|
|
66
|
+
@pastel.green.bold("#{ready.size}/#{opportunities.size} tasks eligible for optimization")
|
|
67
|
+
else
|
|
68
|
+
@pastel.yellow("0/#{opportunities.size} tasks ready - check requirements above")
|
|
69
|
+
end
|
|
70
|
+
output << ''
|
|
71
|
+
|
|
72
|
+
output.join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Format a single optimization opportunity
|
|
76
|
+
#
|
|
77
|
+
# @param opp [Hash] Opportunity data
|
|
78
|
+
# @param ready [Boolean] Whether task is ready for optimization
|
|
79
|
+
# @return [String] Formatted output
|
|
80
|
+
def format_opportunity(opp, ready:)
|
|
81
|
+
output = []
|
|
82
|
+
|
|
83
|
+
# Task name
|
|
84
|
+
status_icon = ready ? @pastel.green('✓') : @pastel.yellow('⚠')
|
|
85
|
+
output << " #{status_icon} #{@pastel.bold(opp[:task_name])}"
|
|
86
|
+
|
|
87
|
+
# Metrics
|
|
88
|
+
exec_label = @pastel.dim(' Executions:')
|
|
89
|
+
exec_value = format_count_status(opp[:execution_count], 10, ready)
|
|
90
|
+
output << "#{exec_label} #{exec_value}"
|
|
91
|
+
|
|
92
|
+
cons_label = @pastel.dim(' Consistency:')
|
|
93
|
+
cons_value = format_percentage_status(opp[:consistency_score], 0.85, ready)
|
|
94
|
+
output << "#{cons_label} #{cons_value}"
|
|
95
|
+
|
|
96
|
+
# Pattern or reason
|
|
97
|
+
if ready && opp[:common_pattern]
|
|
98
|
+
output << "#{@pastel.dim(' Pattern:')} #{opp[:common_pattern]}"
|
|
99
|
+
elsif opp[:reason]
|
|
100
|
+
output << "#{@pastel.dim(' Reason:')} #{@pastel.yellow(opp[:reason])}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
output.join("\n")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Format optimization proposal with diff and metrics
|
|
107
|
+
#
|
|
108
|
+
# @param proposal [Hash] Proposal data
|
|
109
|
+
# @return [String] Formatted output
|
|
110
|
+
def format_proposal(proposal:)
|
|
111
|
+
output = []
|
|
112
|
+
output << ''
|
|
113
|
+
output << @pastel.bold("Optimization Proposal: #{proposal[:task_name]}")
|
|
114
|
+
output << @pastel.dim('=' * 70)
|
|
115
|
+
output << ''
|
|
116
|
+
|
|
117
|
+
# Current code
|
|
118
|
+
output << @pastel.yellow.bold('Current (Neural):')
|
|
119
|
+
output << @pastel.dim('─' * 70)
|
|
120
|
+
proposal[:current_code].each_line do |line|
|
|
121
|
+
output << @pastel.yellow(" #{line.rstrip}")
|
|
122
|
+
end
|
|
123
|
+
output << ''
|
|
124
|
+
|
|
125
|
+
# Proposed code
|
|
126
|
+
output << @pastel.green.bold('Proposed (Symbolic):')
|
|
127
|
+
output << @pastel.dim('─' * 70)
|
|
128
|
+
proposal[:proposed_code].each_line do |line|
|
|
129
|
+
output << @pastel.green(" #{line.rstrip}")
|
|
130
|
+
end
|
|
131
|
+
output << ''
|
|
132
|
+
|
|
133
|
+
# Performance impact
|
|
134
|
+
output << @pastel.bold('Performance Impact:')
|
|
135
|
+
output << @pastel.dim('─' * 70)
|
|
136
|
+
impact = proposal[:performance_impact]
|
|
137
|
+
output << format_impact_line('Execution Time:', impact[:current_avg_time], impact[:optimized_avg_time], 's', impact[:time_reduction_pct])
|
|
138
|
+
output << format_impact_line('Cost Per Call:', impact[:current_avg_cost], impact[:optimized_avg_cost], '$', impact[:cost_reduction_pct])
|
|
139
|
+
output << ''
|
|
140
|
+
output << " #{@pastel.dim('Projected Monthly Savings:')} #{@pastel.green.bold("$#{impact[:projected_monthly_savings]}")}"
|
|
141
|
+
output << ''
|
|
142
|
+
|
|
143
|
+
# Metadata
|
|
144
|
+
output << @pastel.bold('Analysis:')
|
|
145
|
+
output << @pastel.dim('─' * 70)
|
|
146
|
+
output << " #{@pastel.dim('Executions Observed:')} #{proposal[:execution_count]}"
|
|
147
|
+
output << " #{@pastel.dim('Pattern Consistency:')} #{format_percentage(proposal[:consistency_score])}"
|
|
148
|
+
output << " #{@pastel.dim('Tool Sequence:')} #{proposal[:pattern]}"
|
|
149
|
+
output << " #{@pastel.dim('Validation:')} #{proposal[:validation_violations].empty? ? @pastel.green('✓ Passed') : @pastel.red('✗ Failed')}"
|
|
150
|
+
output << ''
|
|
151
|
+
|
|
152
|
+
output.join("\n")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Format success message after applying optimization
|
|
156
|
+
#
|
|
157
|
+
# @param result [Hash] Application result
|
|
158
|
+
# @return [String] Formatted output
|
|
159
|
+
def format_success(result:)
|
|
160
|
+
output = []
|
|
161
|
+
output << ''
|
|
162
|
+
|
|
163
|
+
if result[:success]
|
|
164
|
+
output << @pastel.green.bold('✓ Optimization applied successfully!')
|
|
165
|
+
output << ''
|
|
166
|
+
output << " Task '#{result[:task_name]}' has been optimized to symbolic execution."
|
|
167
|
+
output << ''
|
|
168
|
+
output << @pastel.dim('Next steps:')
|
|
169
|
+
output << " • Monitor performance: aictl agent logs #{result[:task_name]}"
|
|
170
|
+
output << " • View changes: aictl agent code #{result[:task_name]}"
|
|
171
|
+
else
|
|
172
|
+
output << @pastel.red.bold('✗ Optimization failed!')
|
|
173
|
+
output << ''
|
|
174
|
+
output << " Task: #{result[:task_name]}"
|
|
175
|
+
output << " Error: #{result[:error]}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
output << ''
|
|
179
|
+
output.join("\n")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# Format a count with status indicator
|
|
185
|
+
def format_count_status(count, threshold, ready)
|
|
186
|
+
if count >= threshold
|
|
187
|
+
@pastel.green("#{count} (≥#{threshold})")
|
|
188
|
+
else
|
|
189
|
+
ready ? @pastel.yellow("#{count}/#{threshold}") : @pastel.red("#{count}/#{threshold}")
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Format a percentage with status indicator
|
|
194
|
+
def format_percentage_status(score, threshold, ready)
|
|
195
|
+
return @pastel.red('N/A') if score.nil?
|
|
196
|
+
|
|
197
|
+
pct = (score * 100).round(1)
|
|
198
|
+
threshold_pct = (threshold * 100).round(1)
|
|
199
|
+
|
|
200
|
+
if score >= threshold
|
|
201
|
+
@pastel.green("#{pct}% (≥#{threshold_pct}%)")
|
|
202
|
+
else
|
|
203
|
+
ready ? @pastel.yellow("#{pct}%/#{threshold_pct}%") : @pastel.red("#{pct}%/#{threshold_pct}%")
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Format percentage value
|
|
208
|
+
def format_percentage(score)
|
|
209
|
+
return @pastel.red('N/A') if score.nil?
|
|
210
|
+
|
|
211
|
+
pct = (score * 100).round(1)
|
|
212
|
+
@pastel.green("#{pct}%")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Format performance impact line
|
|
216
|
+
def format_impact_line(label, current, optimized, unit, reduction_pct)
|
|
217
|
+
current_str = unit == '$' ? format('$%.4f', current) : "#{current}#{unit}"
|
|
218
|
+
optimized_str = unit == '$' ? format('$%.4f', optimized) : "#{optimized}#{unit}"
|
|
219
|
+
|
|
220
|
+
" #{@pastel.dim(label)} #{current_str} → #{@pastel.green(optimized_str)} " \
|
|
221
|
+
"#{@pastel.green("(#{reduction_pct}% faster)")}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -115,9 +115,18 @@ module LanguageOperator
|
|
|
115
115
|
|
|
116
116
|
# Get all available tools from connected servers
|
|
117
117
|
#
|
|
118
|
-
#
|
|
118
|
+
# Wraps MCP tools with OpenTelemetry instrumentation to trace tool executions.
|
|
119
|
+
#
|
|
120
|
+
# @return [Array] Array of instrumented tool objects
|
|
119
121
|
def tools
|
|
120
|
-
@clients.flat_map(&:tools)
|
|
122
|
+
raw_tools = @clients.flat_map(&:tools)
|
|
123
|
+
|
|
124
|
+
# Wrap each tool with instrumentation if telemetry is enabled
|
|
125
|
+
if ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil)
|
|
126
|
+
raw_tools.map { |tool| wrap_tool_with_instrumentation(tool) }
|
|
127
|
+
else
|
|
128
|
+
raw_tools
|
|
129
|
+
end
|
|
121
130
|
end
|
|
122
131
|
|
|
123
132
|
# Get information about connected servers
|
|
@@ -165,6 +174,69 @@ module LanguageOperator
|
|
|
165
174
|
'Client'
|
|
166
175
|
end
|
|
167
176
|
|
|
177
|
+
# Wrap an MCP tool with OpenTelemetry instrumentation
|
|
178
|
+
#
|
|
179
|
+
# Creates a wrapper that traces tool executions with proper semantic conventions.
|
|
180
|
+
# The wrapper preserves the original tool's interface while adding telemetry.
|
|
181
|
+
#
|
|
182
|
+
# @param tool [Object] Original MCP tool object
|
|
183
|
+
# @return [Object] Instrumented tool wrapper
|
|
184
|
+
def wrap_tool_with_instrumentation(tool)
|
|
185
|
+
# Create a new tool object that wraps the original
|
|
186
|
+
tool_wrapper = Object.new
|
|
187
|
+
tool_wrapper.define_singleton_method(:name) { tool.name }
|
|
188
|
+
tool_wrapper.define_singleton_method(:description) { tool.description }
|
|
189
|
+
tool_wrapper.define_singleton_method(:parameters) { tool.parameters }
|
|
190
|
+
tool_wrapper.define_singleton_method(:params_schema) { tool.params_schema }
|
|
191
|
+
tool_wrapper.define_singleton_method(:provider_params) { tool.provider_params }
|
|
192
|
+
|
|
193
|
+
# Wrap the call method with instrumentation
|
|
194
|
+
original_tool = tool
|
|
195
|
+
tool_wrapper.define_singleton_method(:call) do |arguments|
|
|
196
|
+
# Get current OpenTelemetry context at call time (not definition time)
|
|
197
|
+
# This ensures we pick up the active span from the task execution
|
|
198
|
+
tracer = OpenTelemetry.tracer_provider.tracer('language-operator')
|
|
199
|
+
tool_name = original_tool.name
|
|
200
|
+
|
|
201
|
+
# Get current span to check if we have a parent
|
|
202
|
+
current_span = OpenTelemetry::Trace.current_span
|
|
203
|
+
current_span_context = current_span.context if current_span
|
|
204
|
+
|
|
205
|
+
# Log trace ID for debugging
|
|
206
|
+
if current_span_context&.valid?
|
|
207
|
+
trace_id = current_span_context.hex_trace_id
|
|
208
|
+
LanguageOperator.logger.debug('Tool call with parent trace', tool: tool_name, trace_id: trace_id) if defined?(LanguageOperator.logger)
|
|
209
|
+
elsif defined?(LanguageOperator.logger)
|
|
210
|
+
LanguageOperator.logger.debug('Tool call without parent trace', tool: tool_name)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Create span in the current context - this will automatically attach to
|
|
214
|
+
# the active span if one exists
|
|
215
|
+
tracer.in_span("execute_tool.#{tool_name}", attributes: {
|
|
216
|
+
'gen_ai.operation.name' => 'execute_tool',
|
|
217
|
+
'gen_ai.tool.name' => tool_name,
|
|
218
|
+
'gen_ai.tool.call.arguments' => arguments.to_json[0..1000],
|
|
219
|
+
'gen_ai.tool.call.arguments.size' => arguments.to_json.bytesize
|
|
220
|
+
}) do |span|
|
|
221
|
+
# Execute the original tool
|
|
222
|
+
result = original_tool.call(arguments)
|
|
223
|
+
|
|
224
|
+
# Record the result (truncated for telemetry)
|
|
225
|
+
result_str = result.is_a?(String) ? result : result.to_json
|
|
226
|
+
span.set_attribute('gen_ai.tool.call.result.size', result_str.bytesize)
|
|
227
|
+
span.set_attribute('gen_ai.tool.call.result', result_str[0..2000])
|
|
228
|
+
|
|
229
|
+
result
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
span.record_exception(e)
|
|
232
|
+
span.status = OpenTelemetry::Trace::Status.error("Tool execution failed: #{e.message}")
|
|
233
|
+
raise
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
tool_wrapper
|
|
238
|
+
end
|
|
239
|
+
|
|
168
240
|
# Configure RubyLLM with provider settings
|
|
169
241
|
#
|
|
170
242
|
# @raise [RuntimeError] If provider is unknown
|
|
@@ -12,8 +12,6 @@ module LanguageOperator
|
|
|
12
12
|
def connect_mcp_servers
|
|
13
13
|
enabled_servers = @config['mcp_servers'].select { |s| s['enabled'] }
|
|
14
14
|
|
|
15
|
-
all_tools = []
|
|
16
|
-
|
|
17
15
|
if enabled_servers.empty?
|
|
18
16
|
logger.info('No MCP servers configured, agent will run without tools')
|
|
19
17
|
else
|
|
@@ -25,7 +23,6 @@ module LanguageOperator
|
|
|
25
23
|
|
|
26
24
|
@clients << client
|
|
27
25
|
tool_count = client.tools.length
|
|
28
|
-
all_tools.concat(client.tools)
|
|
29
26
|
|
|
30
27
|
# Debug: inspect tool objects
|
|
31
28
|
if @debug
|
|
@@ -50,21 +47,22 @@ module LanguageOperator
|
|
|
50
47
|
end
|
|
51
48
|
|
|
52
49
|
logger.info('MCP connection summary',
|
|
53
|
-
connected_servers: @clients.length
|
|
54
|
-
total_tools: all_tools.length)
|
|
50
|
+
connected_servers: @clients.length)
|
|
55
51
|
end
|
|
56
52
|
|
|
57
53
|
# Create chat with all collected tools (even if empty)
|
|
54
|
+
# Use the tools() method which applies OpenTelemetry instrumentation wrapping
|
|
58
55
|
llm_config = @config['llm']
|
|
59
56
|
chat_params = build_chat_params(llm_config)
|
|
60
57
|
@chat = RubyLLM.chat(**chat_params)
|
|
61
58
|
|
|
59
|
+
all_tools = tools
|
|
62
60
|
@chat.with_tools(*all_tools) unless all_tools.empty?
|
|
63
61
|
|
|
64
62
|
# Set up callbacks to log tool invocations
|
|
65
63
|
setup_tool_callbacks
|
|
66
64
|
|
|
67
|
-
logger.info('Chat session initialized', with_tools: !all_tools.empty
|
|
65
|
+
logger.info('Chat session initialized', with_tools: !all_tools.empty?, total_tools: all_tools.length)
|
|
68
66
|
end
|
|
69
67
|
|
|
70
68
|
# Connect to MCP server with exponential backoff retry logic
|
|
@@ -229,15 +229,16 @@ module LanguageOperator
|
|
|
229
229
|
# Execute symbolic implementation
|
|
230
230
|
#
|
|
231
231
|
# @param inputs [Hash] Validated inputs
|
|
232
|
-
# @param context [Object, nil] Execution context
|
|
232
|
+
# @param context [Object, nil] Execution context (TaskExecutor)
|
|
233
233
|
# @return [Hash] Result from execute block
|
|
234
234
|
def execute_symbolic(inputs, context)
|
|
235
|
-
if
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
235
|
+
if context
|
|
236
|
+
# Execute block in context's scope to make helper methods available
|
|
237
|
+
# (execute_tool, execute_task, execute_llm, etc.)
|
|
238
|
+
context.instance_exec(inputs, &@execute_block)
|
|
239
239
|
else
|
|
240
|
-
|
|
240
|
+
# Fallback for standalone execution without context
|
|
241
|
+
@execute_block.call(inputs)
|
|
241
242
|
end
|
|
242
243
|
end
|
|
243
244
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LanguageOperator
|
|
4
|
+
module Learning
|
|
5
|
+
module Adapters
|
|
6
|
+
# Abstract base class for OTLP backend query adapters
|
|
7
|
+
#
|
|
8
|
+
# Defines the interface that all backend adapters must implement.
|
|
9
|
+
# Adapters translate generic query requests into backend-specific
|
|
10
|
+
# API calls and normalize responses.
|
|
11
|
+
#
|
|
12
|
+
# @example Implementing a custom adapter
|
|
13
|
+
# class CustomAdapter < BaseAdapter
|
|
14
|
+
# def self.available?(endpoint, api_key = nil)
|
|
15
|
+
# # Check if backend is reachable
|
|
16
|
+
# true
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# def query_spans(filter:, time_range:, limit:)
|
|
20
|
+
# # Query backend and return normalized spans
|
|
21
|
+
# []
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
class BaseAdapter
|
|
25
|
+
# Initialize adapter with connection details
|
|
26
|
+
#
|
|
27
|
+
# @param endpoint [String] Backend endpoint URL
|
|
28
|
+
# @param api_key [String, nil] API key for authentication (if required)
|
|
29
|
+
# @param options [Hash] Additional adapter-specific options
|
|
30
|
+
def initialize(endpoint, api_key = nil, **options)
|
|
31
|
+
@endpoint = endpoint
|
|
32
|
+
@api_key = api_key
|
|
33
|
+
@logger = options[:logger] || ::Logger.new($stdout, level: ::Logger::WARN)
|
|
34
|
+
@options = options
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Check if this backend is available at the given endpoint
|
|
38
|
+
#
|
|
39
|
+
# @param endpoint [String] Backend endpoint URL
|
|
40
|
+
# @param api_key [String, nil] API key for authentication (optional)
|
|
41
|
+
# @return [Boolean] True if backend is reachable and compatible
|
|
42
|
+
def self.available?(endpoint, api_key = nil)
|
|
43
|
+
raise NotImplementedError, "#{self}.available? must be implemented"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Query spans from the backend
|
|
47
|
+
#
|
|
48
|
+
# @param filter [Hash] Filter criteria
|
|
49
|
+
# @option filter [String] :task_name Task name to filter by
|
|
50
|
+
# @option filter [Hash] :attributes Additional span attributes to match
|
|
51
|
+
# @param time_range [Range<Time>] Time range for query
|
|
52
|
+
# @param limit [Integer] Maximum number of spans to return
|
|
53
|
+
# @return [Array<Hash>] Array of normalized span hashes
|
|
54
|
+
def query_spans(filter:, time_range:, limit:)
|
|
55
|
+
raise NotImplementedError, "#{self.class}#query_spans must be implemented"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Extract task execution data from spans
|
|
59
|
+
#
|
|
60
|
+
# Groups spans by trace and extracts task-specific metadata:
|
|
61
|
+
# - inputs: Task input parameters
|
|
62
|
+
# - outputs: Task output values
|
|
63
|
+
# - tool_calls: Sequence of tools invoked
|
|
64
|
+
# - duration: Execution duration
|
|
65
|
+
#
|
|
66
|
+
# @param spans [Array<Hash>] Raw spans from backend
|
|
67
|
+
# @return [Array<Hash>] Task execution data grouped by trace
|
|
68
|
+
def extract_task_data(spans)
|
|
69
|
+
spans.group_by { |span| span[:trace_id] }.map do |trace_id, trace_spans|
|
|
70
|
+
task_span = trace_spans.find { |s| s[:name]&.include?('task_executor') }
|
|
71
|
+
next unless task_span
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
trace_id: trace_id,
|
|
75
|
+
task_name: task_span.dig(:attributes, 'task.name'),
|
|
76
|
+
inputs: extract_inputs(task_span),
|
|
77
|
+
outputs: extract_outputs(task_span),
|
|
78
|
+
tool_calls: extract_tool_calls(trace_spans),
|
|
79
|
+
duration_ms: task_span[:duration_ms],
|
|
80
|
+
timestamp: task_span[:timestamp]
|
|
81
|
+
}
|
|
82
|
+
end.compact
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
protected
|
|
86
|
+
|
|
87
|
+
attr_reader :endpoint, :api_key, :options
|
|
88
|
+
|
|
89
|
+
# Extract inputs from task span attributes
|
|
90
|
+
#
|
|
91
|
+
# @param span [Hash] Task execution span
|
|
92
|
+
# @return [Hash] Input parameters
|
|
93
|
+
def extract_inputs(span)
|
|
94
|
+
attrs = span[:attributes] || {}
|
|
95
|
+
input_keys = attrs['task.input.keys']&.split(',') || []
|
|
96
|
+
|
|
97
|
+
input_keys.each_with_object({}) do |key, inputs|
|
|
98
|
+
value_attr = "task.input.#{key}"
|
|
99
|
+
inputs[key.to_sym] = attrs[value_attr] if attrs[value_attr]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Extract outputs from task span attributes
|
|
104
|
+
#
|
|
105
|
+
# @param span [Hash] Task execution span
|
|
106
|
+
# @return [Hash] Output values
|
|
107
|
+
def extract_outputs(span)
|
|
108
|
+
attrs = span[:attributes] || {}
|
|
109
|
+
output_keys = attrs['task.output.keys']&.split(',') || []
|
|
110
|
+
|
|
111
|
+
output_keys.each_with_object({}) do |key, outputs|
|
|
112
|
+
value_attr = "task.output.#{key}"
|
|
113
|
+
outputs[key.to_sym] = attrs[value_attr] if attrs[value_attr]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Extract tool call sequence from trace spans
|
|
118
|
+
#
|
|
119
|
+
# @param trace_spans [Array<Hash>] All spans in trace
|
|
120
|
+
# @return [Array<Hash>] Ordered tool call sequence
|
|
121
|
+
def extract_tool_calls(trace_spans)
|
|
122
|
+
trace_spans
|
|
123
|
+
.select { |s| s.dig(:attributes, 'gen_ai.operation.name') == 'execute_tool' }
|
|
124
|
+
.sort_by { |s| s[:timestamp] }
|
|
125
|
+
.map do |tool_span|
|
|
126
|
+
{
|
|
127
|
+
tool_name: tool_span.dig(:attributes, 'gen_ai.tool.name'),
|
|
128
|
+
arguments: tool_span.dig(:attributes, 'gen_ai.tool.call.arguments'),
|
|
129
|
+
arguments_size: tool_span.dig(:attributes, 'gen_ai.tool.call.arguments.size'),
|
|
130
|
+
result: tool_span.dig(:attributes, 'gen_ai.tool.call.result'),
|
|
131
|
+
result_size: tool_span.dig(:attributes, 'gen_ai.tool.call.result.size')
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Parse time range into backend-specific format
|
|
137
|
+
#
|
|
138
|
+
# @param time_range [Range<Time>] Time range
|
|
139
|
+
# @return [Hash] Start and end times
|
|
140
|
+
def parse_time_range(time_range)
|
|
141
|
+
{
|
|
142
|
+
start: time_range.begin || (Time.now - (24 * 60 * 60)),
|
|
143
|
+
end: time_range.end || Time.now
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|