language-operator 0.1.31 → 0.1.35
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/.rubocop.yml +7 -8
- data/CHANGELOG.md +14 -0
- data/CI_STATUS.md +56 -0
- data/Gemfile.lock +2 -2
- data/Makefile +22 -6
- data/lib/language_operator/agent/base.rb +10 -6
- data/lib/language_operator/agent/executor.rb +19 -97
- data/lib/language_operator/agent/safety/ast_validator.rb +62 -43
- data/lib/language_operator/agent/safety/safe_executor.rb +27 -2
- data/lib/language_operator/agent/scheduler.rb +60 -0
- data/lib/language_operator/agent/task_executor.rb +548 -0
- data/lib/language_operator/agent.rb +90 -27
- data/lib/language_operator/cli/base_command.rb +117 -0
- data/lib/language_operator/cli/commands/agent.rb +339 -407
- data/lib/language_operator/cli/commands/cluster.rb +274 -290
- data/lib/language_operator/cli/commands/install.rb +110 -119
- data/lib/language_operator/cli/commands/model.rb +284 -184
- data/lib/language_operator/cli/commands/persona.rb +218 -284
- data/lib/language_operator/cli/commands/quickstart.rb +4 -5
- data/lib/language_operator/cli/commands/status.rb +31 -35
- data/lib/language_operator/cli/commands/system.rb +221 -233
- data/lib/language_operator/cli/commands/tool.rb +356 -422
- data/lib/language_operator/cli/commands/use.rb +19 -22
- data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +0 -18
- data/lib/language_operator/cli/wizards/quickstart_wizard.rb +0 -1
- data/lib/language_operator/client/config.rb +20 -21
- data/lib/language_operator/config.rb +115 -3
- data/lib/language_operator/constants.rb +54 -0
- data/lib/language_operator/dsl/agent_context.rb +7 -7
- data/lib/language_operator/dsl/agent_definition.rb +111 -26
- data/lib/language_operator/dsl/config.rb +30 -66
- data/lib/language_operator/dsl/main_definition.rb +114 -0
- data/lib/language_operator/dsl/schema.rb +84 -43
- data/lib/language_operator/dsl/task_definition.rb +315 -0
- data/lib/language_operator/dsl.rb +0 -1
- data/lib/language_operator/instrumentation/task_tracer.rb +285 -0
- data/lib/language_operator/logger.rb +4 -4
- data/lib/language_operator/synthesis_test_harness.rb +324 -0
- data/lib/language_operator/templates/examples/agent_synthesis.tmpl +26 -8
- data/lib/language_operator/templates/schema/CHANGELOG.md +26 -0
- data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
- data/lib/language_operator/templates/schema/agent_dsl_schema.json +84 -42
- data/lib/language_operator/type_coercion.rb +250 -0
- data/lib/language_operator/ux/base.rb +81 -0
- data/lib/language_operator/ux/concerns/README.md +155 -0
- data/lib/language_operator/ux/concerns/headings.rb +90 -0
- data/lib/language_operator/ux/concerns/input_validation.rb +146 -0
- data/lib/language_operator/ux/concerns/provider_helpers.rb +167 -0
- data/lib/language_operator/ux/create_agent.rb +252 -0
- data/lib/language_operator/ux/create_model.rb +267 -0
- data/lib/language_operator/ux/quickstart.rb +594 -0
- data/lib/language_operator/version.rb +1 -1
- data/lib/language_operator.rb +2 -0
- data/requirements/ARCHITECTURE.md +1 -0
- data/requirements/SCRATCH.md +153 -0
- data/requirements/dsl.md +0 -0
- data/requirements/features +1 -0
- data/requirements/personas +1 -0
- data/requirements/proposals +1 -0
- data/requirements/tasks/iterate.md +14 -15
- data/requirements/tasks/optimize.md +13 -4
- data/synth/001/Makefile +90 -0
- data/synth/001/agent.rb +26 -0
- data/synth/001/agent.yaml +7 -0
- data/synth/001/output.log +44 -0
- data/synth/Makefile +39 -0
- data/synth/README.md +342 -0
- metadata +37 -10
- data/lib/language_operator/dsl/workflow_definition.rb +0 -259
- data/test_agent_dsl.rb +0 -108
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../loggable'
|
|
4
|
+
require_relative '../type_coercion'
|
|
5
|
+
|
|
6
|
+
module LanguageOperator
|
|
7
|
+
module Dsl
|
|
8
|
+
# Task definition for organic functions (DSL v1)
|
|
9
|
+
#
|
|
10
|
+
# Represents an organic function with a stable contract (inputs/outputs) where
|
|
11
|
+
# the implementation can evolve from neural (instructions-based) to symbolic
|
|
12
|
+
# (explicit code) without breaking callers.
|
|
13
|
+
#
|
|
14
|
+
# @example Neural task (LLM-based)
|
|
15
|
+
# task :analyze_data,
|
|
16
|
+
# instructions: "Analyze the data for anomalies",
|
|
17
|
+
# inputs: { data: 'array' },
|
|
18
|
+
# outputs: { issues: 'array', summary: 'string' }
|
|
19
|
+
#
|
|
20
|
+
# @example Symbolic task (explicit code)
|
|
21
|
+
# task :calculate_total,
|
|
22
|
+
# inputs: { items: 'array' },
|
|
23
|
+
# outputs: { total: 'number' }
|
|
24
|
+
# do |inputs|
|
|
25
|
+
# { total: inputs[:items].sum { |i| i['amount'] } }
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example Hybrid task (both instructions and code)
|
|
29
|
+
# task :fetch_user,
|
|
30
|
+
# instructions: "Fetch user data from database",
|
|
31
|
+
# inputs: { user_id: 'integer' },
|
|
32
|
+
# outputs: { user: 'hash', preferences: 'hash' }
|
|
33
|
+
# do |inputs|
|
|
34
|
+
# execute_tool('database', 'get_user', id: inputs[:user_id])
|
|
35
|
+
# end
|
|
36
|
+
class TaskDefinition
|
|
37
|
+
include LanguageOperator::Loggable
|
|
38
|
+
|
|
39
|
+
attr_reader :name, :inputs_schema, :outputs_schema, :instructions_text, :execute_block
|
|
40
|
+
|
|
41
|
+
# Supported types for input/output validation
|
|
42
|
+
SUPPORTED_TYPES = %w[string integer number boolean array hash any].freeze
|
|
43
|
+
|
|
44
|
+
# Initialize a new task definition
|
|
45
|
+
#
|
|
46
|
+
# @param name [Symbol] Task name
|
|
47
|
+
def initialize(name)
|
|
48
|
+
@name = name
|
|
49
|
+
@inputs_schema = {}
|
|
50
|
+
@outputs_schema = {}
|
|
51
|
+
@instructions_text = nil
|
|
52
|
+
@execute_block = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Define or retrieve the input contract
|
|
56
|
+
#
|
|
57
|
+
# @param schema [Hash, nil] Input schema (param_name => type_string)
|
|
58
|
+
# @return [Hash] Current input schema
|
|
59
|
+
# @example
|
|
60
|
+
# inputs { user_id: 'integer', filter: 'string' }
|
|
61
|
+
def inputs(schema = nil)
|
|
62
|
+
return @inputs_schema if schema.nil?
|
|
63
|
+
|
|
64
|
+
validate_schema!(schema, 'inputs')
|
|
65
|
+
@inputs_schema = schema
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Define or retrieve the output contract
|
|
69
|
+
#
|
|
70
|
+
# @param schema [Hash, nil] Output schema (field_name => type_string)
|
|
71
|
+
# @return [Hash] Current output schema
|
|
72
|
+
# @example
|
|
73
|
+
# outputs { user: 'hash', success: 'boolean' }
|
|
74
|
+
def outputs(schema = nil)
|
|
75
|
+
return @outputs_schema if schema.nil?
|
|
76
|
+
|
|
77
|
+
validate_schema!(schema, 'outputs')
|
|
78
|
+
@outputs_schema = schema
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Define or retrieve the instructions (neural implementation)
|
|
82
|
+
#
|
|
83
|
+
# @param text [String, nil] Natural language instructions
|
|
84
|
+
# @return [String, nil] Current instructions
|
|
85
|
+
# @example
|
|
86
|
+
# instructions "Fetch user data from the database"
|
|
87
|
+
def instructions(text = nil)
|
|
88
|
+
return @instructions_text if text.nil?
|
|
89
|
+
|
|
90
|
+
@instructions_text = text
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Define the symbolic implementation
|
|
94
|
+
#
|
|
95
|
+
# @yield [inputs] Block that receives validated inputs and returns outputs
|
|
96
|
+
# @yieldparam inputs [Hash] Validated and coerced input parameters
|
|
97
|
+
# @yieldreturn [Hash] Output values matching the outputs schema
|
|
98
|
+
# @example
|
|
99
|
+
# execute do |inputs|
|
|
100
|
+
# { total: inputs[:items].sum { |i| i['amount'] } }
|
|
101
|
+
# end
|
|
102
|
+
def execute(&block)
|
|
103
|
+
@execute_block = block if block
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Check if this is a neural task (instructions-based)
|
|
107
|
+
#
|
|
108
|
+
# @return [Boolean] True if instructions are defined
|
|
109
|
+
def neural?
|
|
110
|
+
!@instructions_text.nil?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Check if this is a symbolic task (code-based)
|
|
114
|
+
#
|
|
115
|
+
# @return [Boolean] True if execute block is defined
|
|
116
|
+
def symbolic?
|
|
117
|
+
!@execute_block.nil?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Execute the task with given inputs
|
|
121
|
+
#
|
|
122
|
+
# @param input_params [Hash] Input parameters
|
|
123
|
+
# @param context [Object, nil] Execution context (optional)
|
|
124
|
+
# @return [Hash] Validated output matching outputs schema
|
|
125
|
+
# @raise [ArgumentError] If inputs or outputs don't match schema
|
|
126
|
+
def call(input_params, context = nil)
|
|
127
|
+
# Validate and coerce inputs
|
|
128
|
+
validated_inputs = validate_inputs(input_params)
|
|
129
|
+
|
|
130
|
+
# Execute based on implementation type
|
|
131
|
+
result = if symbolic?
|
|
132
|
+
# Symbolic execution (explicit code)
|
|
133
|
+
logger.debug('Executing symbolic task', task: @name)
|
|
134
|
+
execute_symbolic(validated_inputs, context)
|
|
135
|
+
elsif neural?
|
|
136
|
+
# Neural execution (LLM-based)
|
|
137
|
+
logger.debug('Executing neural task', task: @name, instructions: @instructions_text)
|
|
138
|
+
execute_neural(validated_inputs, context)
|
|
139
|
+
else
|
|
140
|
+
raise "Task #{@name} has no implementation (neither neural nor symbolic)"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Validate outputs
|
|
144
|
+
validate_outputs(result)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Validate input parameters against schema
|
|
148
|
+
#
|
|
149
|
+
# @param params [Hash] Input parameters
|
|
150
|
+
# @return [Hash] Validated and coerced parameters
|
|
151
|
+
# @raise [ArgumentError] If validation fails
|
|
152
|
+
def validate_inputs(params)
|
|
153
|
+
params = params.transform_keys(&:to_sym)
|
|
154
|
+
validated = {}
|
|
155
|
+
|
|
156
|
+
@inputs_schema.each do |key, type|
|
|
157
|
+
key_sym = key.to_sym
|
|
158
|
+
value = params[key_sym]
|
|
159
|
+
|
|
160
|
+
raise ArgumentError, "Missing required input parameter: #{key}" if value.nil?
|
|
161
|
+
|
|
162
|
+
validated[key_sym] = coerce_value(value, type, "input parameter '#{key}'")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Check for unexpected parameters
|
|
166
|
+
extra_keys = params.keys - @inputs_schema.keys.map(&:to_sym)
|
|
167
|
+
logger.warn('Unexpected input parameters', task: @name, extra: extra_keys) unless extra_keys.empty?
|
|
168
|
+
|
|
169
|
+
validated
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Validate output values against schema
|
|
173
|
+
#
|
|
174
|
+
# @param result [Hash] Output values
|
|
175
|
+
# @return [Hash] Validated and coerced outputs
|
|
176
|
+
# @raise [ArgumentError] If validation fails
|
|
177
|
+
def validate_outputs(result)
|
|
178
|
+
return result if @outputs_schema.empty? # No schema = no validation
|
|
179
|
+
|
|
180
|
+
result = result.transform_keys(&:to_sym)
|
|
181
|
+
validated = {}
|
|
182
|
+
|
|
183
|
+
@outputs_schema.each do |key, type|
|
|
184
|
+
key_sym = key.to_sym
|
|
185
|
+
value = result[key_sym]
|
|
186
|
+
|
|
187
|
+
raise ArgumentError, "Missing required output field: #{key}" if value.nil?
|
|
188
|
+
|
|
189
|
+
validated[key_sym] = coerce_value(value, type, "output field '#{key}'")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
validated
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Export task as JSON schema
|
|
196
|
+
#
|
|
197
|
+
# @return [Hash] JSON Schema representation
|
|
198
|
+
def to_schema
|
|
199
|
+
{
|
|
200
|
+
'name' => @name.to_s,
|
|
201
|
+
'type' => implementation_type,
|
|
202
|
+
'instructions' => @instructions_text,
|
|
203
|
+
'inputs' => schema_to_json(@inputs_schema),
|
|
204
|
+
'outputs' => schema_to_json(@outputs_schema)
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def logger_component
|
|
211
|
+
"Task:#{@name}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Determine implementation type
|
|
215
|
+
#
|
|
216
|
+
# @return [String] 'neural', 'symbolic', or 'hybrid'
|
|
217
|
+
def implementation_type
|
|
218
|
+
if neural? && symbolic?
|
|
219
|
+
'hybrid'
|
|
220
|
+
elsif neural?
|
|
221
|
+
'neural'
|
|
222
|
+
elsif symbolic?
|
|
223
|
+
'symbolic'
|
|
224
|
+
else
|
|
225
|
+
'undefined'
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Execute symbolic implementation
|
|
230
|
+
#
|
|
231
|
+
# @param inputs [Hash] Validated inputs
|
|
232
|
+
# @param context [Object, nil] Execution context
|
|
233
|
+
# @return [Hash] Result from execute block
|
|
234
|
+
def execute_symbolic(inputs, context)
|
|
235
|
+
if @execute_block.arity == 1
|
|
236
|
+
@execute_block.call(inputs)
|
|
237
|
+
elsif @execute_block.arity == 2
|
|
238
|
+
@execute_block.call(inputs, context)
|
|
239
|
+
else
|
|
240
|
+
@execute_block.call
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Execute neural implementation (stub for now)
|
|
245
|
+
#
|
|
246
|
+
# @param inputs [Hash] Validated inputs
|
|
247
|
+
# @param context [Object, nil] Execution context
|
|
248
|
+
# @return [Hash] Result from LLM
|
|
249
|
+
# @note This is a placeholder - actual neural execution happens in agent runtime
|
|
250
|
+
def execute_neural(inputs, context)
|
|
251
|
+
raise NotImplementedError, 'Neural task execution requires agent runtime context. ' \
|
|
252
|
+
"Task #{@name} should be executed via execute_task() in agent main block."
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Validate a schema hash
|
|
256
|
+
#
|
|
257
|
+
# @param schema [Hash] Schema to validate
|
|
258
|
+
# @param name [String] Schema name (for error messages)
|
|
259
|
+
# @raise [ArgumentError] If schema is invalid
|
|
260
|
+
def validate_schema!(schema, name)
|
|
261
|
+
raise ArgumentError, "#{name} schema must be a Hash, got #{schema.class}" unless schema.is_a?(Hash)
|
|
262
|
+
|
|
263
|
+
schema.each do |key, type|
|
|
264
|
+
unless type.is_a?(String) && SUPPORTED_TYPES.include?(type)
|
|
265
|
+
raise ArgumentError, "#{name} schema type for '#{key}' must be one of #{SUPPORTED_TYPES.join(', ')}, " \
|
|
266
|
+
"got '#{type}'"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Coerce a value to the specified type
|
|
272
|
+
#
|
|
273
|
+
# @param value [Object] Value to coerce
|
|
274
|
+
# @param type [String] Target type
|
|
275
|
+
# @param context [String] Context for error messages
|
|
276
|
+
# @return [Object] Coerced value
|
|
277
|
+
# @raise [ArgumentError] If coercion fails
|
|
278
|
+
def coerce_value(value, type, context)
|
|
279
|
+
TypeCoercion.coerce(value, type)
|
|
280
|
+
rescue ArgumentError => e
|
|
281
|
+
# Re-raise with context added
|
|
282
|
+
raise ArgumentError, "#{e.message} for #{context}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Convert schema hash to JSON Schema format
|
|
286
|
+
#
|
|
287
|
+
# @param schema [Hash] Type schema
|
|
288
|
+
# @return [Hash] JSON Schema
|
|
289
|
+
def schema_to_json(schema)
|
|
290
|
+
{
|
|
291
|
+
'type' => 'object',
|
|
292
|
+
'properties' => schema.transform_values { |type| { 'type' => map_type_to_json_schema(type) } },
|
|
293
|
+
'required' => schema.keys.map(&:to_s)
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Map internal type to JSON Schema type
|
|
298
|
+
#
|
|
299
|
+
# @param type [String] Internal type name
|
|
300
|
+
# @return [String] JSON Schema type
|
|
301
|
+
def map_type_to_json_schema(type)
|
|
302
|
+
case type
|
|
303
|
+
when 'integer' then 'integer'
|
|
304
|
+
when 'number' then 'number'
|
|
305
|
+
when 'string' then 'string'
|
|
306
|
+
when 'boolean' then 'boolean'
|
|
307
|
+
when 'array' then 'array'
|
|
308
|
+
when 'hash' then 'object'
|
|
309
|
+
when 'any' then 'any'
|
|
310
|
+
else type
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -13,7 +13,6 @@ require_relative 'dsl/context'
|
|
|
13
13
|
require_relative 'dsl/execution_context'
|
|
14
14
|
require_relative 'dsl/agent_definition'
|
|
15
15
|
require_relative 'dsl/agent_context'
|
|
16
|
-
require_relative 'dsl/workflow_definition'
|
|
17
16
|
require_relative 'dsl/schema'
|
|
18
17
|
require_relative 'agent/safety/ast_validator'
|
|
19
18
|
require_relative 'agent/safety/safe_executor'
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'opentelemetry/sdk'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module LanguageOperator
|
|
7
|
+
module Instrumentation
|
|
8
|
+
# OpenTelemetry instrumentation for task execution
|
|
9
|
+
#
|
|
10
|
+
# Provides comprehensive tracing for DSL v1 task execution including:
|
|
11
|
+
# - Neural task execution (LLM calls with tool access)
|
|
12
|
+
# - Symbolic task execution (direct Ruby code)
|
|
13
|
+
# - Input/output validation
|
|
14
|
+
# - Retry attempts
|
|
15
|
+
# - Tool call tracking (when available)
|
|
16
|
+
#
|
|
17
|
+
# Follows OpenTelemetry Semantic Conventions for GenAI:
|
|
18
|
+
# https://opentelemetry.io/docs/specs/semconv/gen-ai/
|
|
19
|
+
#
|
|
20
|
+
# @example Enable full data capture
|
|
21
|
+
# ENV['CAPTURE_TASK_INPUTS'] = 'true'
|
|
22
|
+
# ENV['CAPTURE_TASK_OUTPUTS'] = 'true'
|
|
23
|
+
# ENV['CAPTURE_TOOL_ARGS'] = 'true'
|
|
24
|
+
#
|
|
25
|
+
# @example Performance overhead
|
|
26
|
+
# Instrumentation adds <5% overhead with default settings
|
|
27
|
+
# Overhead may increase to ~10% with full data capture enabled
|
|
28
|
+
#
|
|
29
|
+
module TaskTracer
|
|
30
|
+
# Maximum length for captured data before truncation
|
|
31
|
+
MAX_CAPTURED_LENGTH = 1000
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Check if data capture is enabled for a specific type
|
|
36
|
+
#
|
|
37
|
+
# @param type [Symbol] Type of data (:inputs, :outputs, :tool_args, :tool_results)
|
|
38
|
+
# @return [Boolean] Whether capture is enabled
|
|
39
|
+
def capture_enabled?(type)
|
|
40
|
+
case type
|
|
41
|
+
when :inputs
|
|
42
|
+
ENV['CAPTURE_TASK_INPUTS'] == 'true'
|
|
43
|
+
when :outputs
|
|
44
|
+
ENV['CAPTURE_TASK_OUTPUTS'] == 'true'
|
|
45
|
+
when :tool_args
|
|
46
|
+
ENV['CAPTURE_TOOL_ARGS'] == 'true'
|
|
47
|
+
when :tool_results
|
|
48
|
+
ENV['CAPTURE_TOOL_RESULTS'] == 'true'
|
|
49
|
+
else
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Sanitize data for span attributes
|
|
55
|
+
#
|
|
56
|
+
# By default, only captures metadata (sizes, counts).
|
|
57
|
+
# Full data capture requires explicit opt-in via environment variables.
|
|
58
|
+
#
|
|
59
|
+
# @param data [Object] Data to sanitize
|
|
60
|
+
# @param type [Symbol] Type of data for capture control
|
|
61
|
+
# @param max_length [Integer] Maximum length before truncation
|
|
62
|
+
# @return [String, nil] Sanitized string or nil if capture disabled
|
|
63
|
+
def sanitize_data(data, type, max_length: MAX_CAPTURED_LENGTH)
|
|
64
|
+
return nil unless capture_enabled?(type)
|
|
65
|
+
|
|
66
|
+
str = case data
|
|
67
|
+
when String then data
|
|
68
|
+
when Hash then JSON.generate(data)
|
|
69
|
+
else data.to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Truncate if too long
|
|
73
|
+
if str.length > max_length
|
|
74
|
+
"#{str[0...max_length]}... (truncated #{str.length - max_length} chars)"
|
|
75
|
+
else
|
|
76
|
+
str
|
|
77
|
+
end
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
logger&.warn('Failed to sanitize data for tracing', error: e.message)
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Build attributes for neural task span following GenAI semantic conventions
|
|
84
|
+
#
|
|
85
|
+
# @param task [TaskDefinition] The task definition
|
|
86
|
+
# @param prompt [String] The generated prompt
|
|
87
|
+
# @param validated_inputs [Hash] Validated input parameters
|
|
88
|
+
# @return [Hash] Span attributes
|
|
89
|
+
def neural_task_attributes(_task, prompt, validated_inputs)
|
|
90
|
+
attributes = {
|
|
91
|
+
'gen_ai.operation.name' => 'chat',
|
|
92
|
+
'gen_ai.system' => determine_genai_system,
|
|
93
|
+
'gen_ai.prompt.size' => prompt.bytesize
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Add model if available
|
|
97
|
+
if @agent.respond_to?(:config) && @agent.config
|
|
98
|
+
model = @agent.config.dig('llm', 'model') || @agent.config['model']
|
|
99
|
+
attributes['gen_ai.request.model'] = model if model
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Add sanitized prompt if capture enabled
|
|
103
|
+
if (sanitized_prompt = sanitize_data(prompt, :inputs))
|
|
104
|
+
attributes['gen_ai.prompt'] = sanitized_prompt
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Add input metadata
|
|
108
|
+
attributes['task.input.keys'] = validated_inputs.keys.map(&:to_s).join(',')
|
|
109
|
+
attributes['task.input.count'] = validated_inputs.size
|
|
110
|
+
|
|
111
|
+
attributes
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Determine GenAI system identifier from agent configuration
|
|
115
|
+
#
|
|
116
|
+
# @return [String] GenAI system identifier
|
|
117
|
+
def determine_genai_system
|
|
118
|
+
return 'ruby_llm' unless @agent.respond_to?(:config)
|
|
119
|
+
|
|
120
|
+
provider = @agent.config.dig('llm', 'provider') ||
|
|
121
|
+
@agent.config['provider'] ||
|
|
122
|
+
'ruby_llm'
|
|
123
|
+
|
|
124
|
+
provider.to_s
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Build attributes for symbolic task span
|
|
128
|
+
#
|
|
129
|
+
# @param task [TaskDefinition] The task definition
|
|
130
|
+
# @return [Hash] Span attributes
|
|
131
|
+
def symbolic_task_attributes(task)
|
|
132
|
+
{
|
|
133
|
+
'task.execution.type' => 'symbolic',
|
|
134
|
+
'task.execution.has_block' => task.execute_block ? 'true' : 'false'
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Record token usage from LLM response on span
|
|
139
|
+
#
|
|
140
|
+
# @param response [Object] LLM response object
|
|
141
|
+
# @param span [OpenTelemetry::Trace::Span] The span to update
|
|
142
|
+
def record_token_usage(response, span)
|
|
143
|
+
return unless response
|
|
144
|
+
|
|
145
|
+
span.set_attribute('gen_ai.usage.input_tokens', response.input_tokens.to_i) if response.respond_to?(:input_tokens) && response.input_tokens
|
|
146
|
+
|
|
147
|
+
span.set_attribute('gen_ai.usage.output_tokens', response.output_tokens.to_i) if response.respond_to?(:output_tokens) && response.output_tokens
|
|
148
|
+
|
|
149
|
+
# Try to get model from response if available
|
|
150
|
+
span.set_attribute('gen_ai.response.model', response.model.to_s) if response.respond_to?(:model) && response.model
|
|
151
|
+
|
|
152
|
+
# Try to get response ID if available
|
|
153
|
+
span.set_attribute('gen_ai.response.id', response.id.to_s) if response.respond_to?(:id) && response.id
|
|
154
|
+
|
|
155
|
+
# Try to get finish reason if available
|
|
156
|
+
span.set_attribute('gen_ai.response.finish_reasons', response.stop_reason.to_s) if response.respond_to?(:stop_reason) && response.stop_reason
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
logger&.warn('Failed to record token usage', error: e.message)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Record tool calls from LLM response
|
|
162
|
+
#
|
|
163
|
+
# Attempts to extract tool call information from the response object
|
|
164
|
+
# and create child spans for each tool invocation.
|
|
165
|
+
#
|
|
166
|
+
# @param response [Object] LLM response object
|
|
167
|
+
# @param parent_span [OpenTelemetry::Trace::Span] Parent span
|
|
168
|
+
def record_tool_calls(response, parent_span)
|
|
169
|
+
return unless response.respond_to?(:tool_calls)
|
|
170
|
+
return unless response.tool_calls&.any?
|
|
171
|
+
|
|
172
|
+
response.tool_calls.each do |tool_call|
|
|
173
|
+
record_single_tool_call(tool_call, parent_span)
|
|
174
|
+
end
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
logger&.warn('Failed to record tool calls', error: e.message)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Record a single tool call as a span
|
|
180
|
+
#
|
|
181
|
+
# @param tool_call [Object] Tool call object
|
|
182
|
+
# @param parent_span [OpenTelemetry::Trace::Span] Parent span
|
|
183
|
+
def record_single_tool_call(tool_call, _parent_span)
|
|
184
|
+
tool_name = extract_tool_name(tool_call)
|
|
185
|
+
|
|
186
|
+
tracer.in_span("execute_tool #{tool_name}", attributes: build_tool_call_attributes(tool_call)) do |tool_span|
|
|
187
|
+
# Tool execution already completed by ruby_llm
|
|
188
|
+
# Just record the metadata
|
|
189
|
+
record_tool_result(tool_call.result, tool_span) if tool_call.respond_to?(:result) && tool_call.result
|
|
190
|
+
end
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
logger&.warn('Failed to record tool call span', error: e.message, tool: tool_name)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Extract tool name from tool call object
|
|
196
|
+
#
|
|
197
|
+
# @param tool_call [Object] Tool call object
|
|
198
|
+
# @return [String] Tool name
|
|
199
|
+
def extract_tool_name(tool_call)
|
|
200
|
+
if tool_call.respond_to?(:name)
|
|
201
|
+
tool_call.name.to_s
|
|
202
|
+
elsif tool_call.respond_to?(:function) && tool_call.function.respond_to?(:name)
|
|
203
|
+
tool_call.function.name.to_s
|
|
204
|
+
else
|
|
205
|
+
'unknown'
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Build attributes for tool call span
|
|
210
|
+
#
|
|
211
|
+
# @param tool_call [Object] Tool call object
|
|
212
|
+
# @return [Hash] Span attributes
|
|
213
|
+
def build_tool_call_attributes(tool_call)
|
|
214
|
+
attributes = {
|
|
215
|
+
'gen_ai.operation.name' => 'execute_tool',
|
|
216
|
+
'gen_ai.tool.name' => extract_tool_name(tool_call)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# Add tool call ID if available
|
|
220
|
+
attributes['gen_ai.tool.call.id'] = tool_call.id.to_s if tool_call.respond_to?(:id) && tool_call.id
|
|
221
|
+
|
|
222
|
+
# Add arguments if available and capture enabled
|
|
223
|
+
if tool_call.respond_to?(:arguments) && tool_call.arguments
|
|
224
|
+
args_str = tool_call.arguments.is_a?(String) ? tool_call.arguments : JSON.generate(tool_call.arguments)
|
|
225
|
+
attributes['gen_ai.tool.call.arguments.size'] = args_str.bytesize
|
|
226
|
+
|
|
227
|
+
if (sanitized_args = sanitize_data(tool_call.arguments, :tool_args))
|
|
228
|
+
attributes['gen_ai.tool.call.arguments'] = sanitized_args
|
|
229
|
+
end
|
|
230
|
+
elsif tool_call.respond_to?(:function) && tool_call.function.respond_to?(:arguments)
|
|
231
|
+
args = tool_call.function.arguments
|
|
232
|
+
args_str = args.is_a?(String) ? args : JSON.generate(args)
|
|
233
|
+
attributes['gen_ai.tool.call.arguments.size'] = args_str.bytesize
|
|
234
|
+
|
|
235
|
+
if (sanitized_args = sanitize_data(args, :tool_args))
|
|
236
|
+
attributes['gen_ai.tool.call.arguments'] = sanitized_args
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
attributes
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Record tool call result on span
|
|
244
|
+
#
|
|
245
|
+
# @param result [Object] Tool call result
|
|
246
|
+
# @param span [OpenTelemetry::Trace::Span] The span to update
|
|
247
|
+
def record_tool_result(result, span)
|
|
248
|
+
result_str = result.is_a?(String) ? result : JSON.generate(result)
|
|
249
|
+
span.set_attribute('gen_ai.tool.call.result.size', result_str.bytesize)
|
|
250
|
+
|
|
251
|
+
if (sanitized_result = sanitize_data(result, :tool_results))
|
|
252
|
+
span.set_attribute('gen_ai.tool.call.result', sanitized_result)
|
|
253
|
+
end
|
|
254
|
+
rescue StandardError => e
|
|
255
|
+
logger&.warn('Failed to record tool result', error: e.message)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Record response parsing metadata
|
|
259
|
+
#
|
|
260
|
+
# @param response_text [String] Raw response text
|
|
261
|
+
# @param span [OpenTelemetry::Trace::Span] The span to update
|
|
262
|
+
def record_parse_metadata(response_text, span)
|
|
263
|
+
span.set_attribute('gen_ai.completion.size', response_text.bytesize)
|
|
264
|
+
|
|
265
|
+
# Add sanitized completion if capture enabled
|
|
266
|
+
if (sanitized_completion = sanitize_data(response_text, :outputs))
|
|
267
|
+
span.set_attribute('gen_ai.completion', sanitized_completion)
|
|
268
|
+
end
|
|
269
|
+
rescue StandardError => e
|
|
270
|
+
logger&.warn('Failed to record parse metadata', error: e.message)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Record output validation metadata
|
|
274
|
+
#
|
|
275
|
+
# @param outputs [Hash] Task outputs
|
|
276
|
+
# @param span [OpenTelemetry::Trace::Span] The span to update
|
|
277
|
+
def record_output_metadata(outputs, span)
|
|
278
|
+
span.set_attribute('task.output.keys', outputs.keys.map(&:to_s).join(','))
|
|
279
|
+
span.set_attribute('task.output.count', outputs.size)
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
logger&.warn('Failed to record output metadata', error: e.message)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -23,10 +23,10 @@ module LanguageOperator
|
|
|
23
23
|
}.freeze
|
|
24
24
|
|
|
25
25
|
LEVEL_EMOJI = {
|
|
26
|
-
'DEBUG' =>
|
|
27
|
-
'INFO' =>
|
|
28
|
-
'WARN' =>
|
|
29
|
-
'ERROR' =>
|
|
26
|
+
'DEBUG' => "\e[1;90m·\e[0m", # Bold gray middot
|
|
27
|
+
'INFO' => "\e[1;36m·\e[0m", # Bold cyan middot
|
|
28
|
+
'WARN' => "\e[1;33m·\e[0m", # Bold yellow middot
|
|
29
|
+
'ERROR' => "\e[1;31m·\e[0m" # Bold red middot
|
|
30
30
|
}.freeze
|
|
31
31
|
|
|
32
32
|
attr_reader :logger, :format, :show_timing
|