language-operator 0.1.63 → 0.1.65

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.plan.md +127 -0
  3. data/.rspec +3 -0
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +4 -1
  6. data/Makefile +34 -80
  7. data/components/agent/Gemfile +1 -1
  8. data/docs/cheat-sheet.md +173 -0
  9. data/lib/language_operator/agent/base.rb +10 -1
  10. data/lib/language_operator/agent/event_config.rb +172 -0
  11. data/lib/language_operator/agent/safety/ast_validator.rb +1 -1
  12. data/lib/language_operator/agent/safety/safe_executor.rb +5 -1
  13. data/lib/language_operator/agent/task_executor.rb +87 -7
  14. data/lib/language_operator/agent/telemetry.rb +25 -3
  15. data/lib/language_operator/agent/web_server.rb +6 -9
  16. data/lib/language_operator/cli/commands/agent/base.rb +15 -17
  17. data/lib/language_operator/cli/commands/agent/learning.rb +156 -37
  18. data/lib/language_operator/cli/commands/cluster.rb +2 -2
  19. data/lib/language_operator/cli/commands/status.rb +2 -2
  20. data/lib/language_operator/cli/commands/system/synthesize.rb +1 -1
  21. data/lib/language_operator/cli/formatters/value_formatter.rb +1 -1
  22. data/lib/language_operator/cli/helpers/ux_helper.rb +3 -4
  23. data/lib/language_operator/config.rb +3 -3
  24. data/lib/language_operator/constants/kubernetes_labels.rb +2 -2
  25. data/lib/language_operator/dsl/task_definition.rb +18 -7
  26. data/lib/language_operator/instrumentation/task_tracer.rb +44 -3
  27. data/lib/language_operator/kubernetes/client.rb +111 -0
  28. data/lib/language_operator/templates/schema/CHANGELOG.md +28 -0
  29. data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
  30. data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
  31. data/lib/language_operator/type_coercion.rb +22 -8
  32. data/lib/language_operator/version.rb +1 -1
  33. data/synth/002/agent.rb +23 -12
  34. data/synth/002/output.log +88 -15
  35. data/synth/003/Makefile +5 -2
  36. data/synth/004/Makefile +54 -0
  37. data/synth/004/README.md +281 -0
  38. data/synth/004/instructions.txt +1 -0
  39. metadata +8 -1
@@ -81,7 +81,8 @@ module LanguageOperator
81
81
  agent = ctx.client.get_resource(RESOURCE_AGENT, name, ctx.namespace)
82
82
 
83
83
  # Check current status
84
- annotations = agent.dig('metadata', 'annotations') || {}
84
+ annotations = agent.dig('metadata', 'annotations')
85
+ annotations = annotations.respond_to?(:to_h) ? annotations.to_h : (annotations || {})
85
86
  disabled_annotation = Constants::KubernetesLabels::LEARNING_DISABLED_LABEL
86
87
 
87
88
  unless annotations.key?(disabled_annotation)
@@ -117,7 +118,8 @@ module LanguageOperator
117
118
  agent = ctx.client.get_resource(RESOURCE_AGENT, name, ctx.namespace)
118
119
 
119
120
  # Check current status
120
- annotations = agent.dig('metadata', 'annotations') || {}
121
+ annotations = agent.dig('metadata', 'annotations')
122
+ annotations = annotations.respond_to?(:to_h) ? annotations.to_h : (annotations || {})
121
123
  disabled_annotation = Constants::KubernetesLabels::LEARNING_DISABLED_LABEL
122
124
 
123
125
  if annotations.key?(disabled_annotation)
@@ -147,45 +149,27 @@ module LanguageOperator
147
149
  end
148
150
 
149
151
  def display_learning_status(agent, learning_status, cluster_name)
150
- agent_name = agent.dig('metadata', 'name')
151
- annotations = agent.dig('metadata', 'annotations') || {}
152
+ agent.dig('metadata', 'name')
153
+ annotations = agent.dig('metadata', 'annotations')
154
+ annotations = annotations.respond_to?(:to_h) ? annotations.to_h : (annotations || {})
152
155
 
153
156
  puts
154
157
 
155
- # Learning enablement status
156
- learning_enabled = !annotations.key?(Constants::KubernetesLabels::LEARNING_DISABLED_LABEL)
157
- status_color = learning_enabled ? :green : :yellow
158
- status_text = learning_enabled ? 'Enabled' : 'Disabled'
158
+ # Display Agent Status box
159
+ display_agent_status_box(agent, cluster_name)
160
+ puts
159
161
 
160
- highlighted_box(
161
- title: 'Learning Status',
162
- rows: {
163
- 'Agent' => pastel.white.bold(agent_name),
164
- 'Cluster' => cluster_name,
165
- 'Learning' => pastel.send(status_color).bold(status_text),
166
- 'Last Updated' => agent.dig('metadata', 'resourceVersion') || 'Unknown'
167
- }
168
- )
162
+ # Display Learning Status box
163
+ display_learning_status_box(agent, learning_status, annotations)
169
164
  puts
170
165
 
171
166
  # If learning status ConfigMap exists, show detailed information
172
167
  if learning_status
173
168
  display_detailed_learning_status(learning_status)
174
169
  else
175
- puts pastel.dim('No learning status data available yet.')
176
- puts pastel.dim('Learning data will appear after the agent has run and the operator has analyzed its behavior.')
177
- puts
170
+ learning_enabled = !annotations.key?(Constants::KubernetesLabels::LEARNING_DISABLED_LABEL)
171
+ display_learning_explanation(learning_enabled)
178
172
  end
179
-
180
- # Show next steps
181
- puts pastel.white.bold('Available Commands:')
182
- if learning_enabled
183
- puts pastel.dim(" aictl agent learning disable #{agent_name}")
184
- else
185
- puts pastel.dim(" aictl agent learning enable #{agent_name}")
186
- end
187
- puts pastel.dim(" aictl agent versions #{agent_name}")
188
- puts pastel.dim(" aictl agent inspect #{agent_name}")
189
173
  end
190
174
 
191
175
  def display_detailed_learning_status(learning_status)
@@ -206,11 +190,7 @@ module LanguageOperator
206
190
  executions = task_info['executions'] || 0
207
191
  status = task_info['status'] || 'neural'
208
192
 
209
- confidence_color = if confidence >= 85
210
- :green
211
- else
212
- confidence >= 70 ? :yellow : :red
213
- end
193
+ confidence_color = determine_confidence_color(confidence)
214
194
 
215
195
  puts " #{pastel.cyan(task_name)}"
216
196
  puts " Status: #{format_task_status(status)}"
@@ -261,7 +241,8 @@ module LanguageOperator
261
241
  agent = client.get_resource(RESOURCE_AGENT, name, namespace)
262
242
 
263
243
  # Add annotation
264
- annotations = agent.dig('metadata', 'annotations') || {}
244
+ annotations = agent.dig('metadata', 'annotations')
245
+ annotations = annotations.respond_to?(:to_h) ? annotations.to_h : (annotations || {})
265
246
  annotations[annotation_key] = annotation_value
266
247
  agent['metadata']['annotations'] = annotations
267
248
 
@@ -274,13 +255,151 @@ module LanguageOperator
274
255
  agent = client.get_resource(RESOURCE_AGENT, name, namespace)
275
256
 
276
257
  # Remove annotation
277
- annotations = agent.dig('metadata', 'annotations') || {}
258
+ annotations = agent.dig('metadata', 'annotations')
259
+ annotations = annotations.respond_to?(:to_h) ? annotations.to_h : (annotations || {})
278
260
  annotations.delete(annotation_key)
279
261
  agent['metadata']['annotations'] = annotations
280
262
 
281
263
  # Update the agent
282
264
  client.update_resource(agent)
283
265
  end
266
+
267
+ def format_agent_timestamp(agent)
268
+ created_time = agent.dig('metadata', 'creationTimestamp')
269
+ return 'Unknown' unless created_time
270
+
271
+ begin
272
+ Time.parse(created_time).strftime('%Y-%m-%d %H:%M:%S UTC')
273
+ rescue StandardError
274
+ 'Unknown'
275
+ end
276
+ end
277
+
278
+ def display_agent_status_box(agent, cluster_name)
279
+ agent_name = agent.dig('metadata', 'name')
280
+ timestamp = format_agent_timestamp(agent)
281
+
282
+ # Get agent operational status
283
+ status = agent['status']
284
+ if status && status['conditions']
285
+ ready_condition = status['conditions'].find { |c| c['type'] == 'Ready' }
286
+ if ready_condition
287
+ begin
288
+ last_activity = Time.parse(ready_condition['lastTransitionTime']).strftime('%Y-%m-%d %H:%M:%S UTC')
289
+ status_text = ready_condition['status'] == 'True' ? 'Ready' : 'Not Ready'
290
+ status_colored = ready_condition['status'] == 'True' ? pastel.green(status_text) : pastel.yellow(status_text)
291
+ rescue StandardError
292
+ last_activity = 'Unknown'
293
+ status_colored = pastel.dim('Unknown')
294
+ end
295
+ else
296
+ last_activity = 'Unknown'
297
+ status_colored = pastel.dim('Unknown')
298
+ end
299
+ else
300
+ last_activity = 'Unknown'
301
+ status_colored = pastel.dim('Unknown')
302
+ end
303
+
304
+ highlighted_box(
305
+ title: 'Agent Status',
306
+ color: :yellow,
307
+ rows: {
308
+ 'Name' => pastel.white.bold(agent_name),
309
+ 'Cluster' => cluster_name,
310
+ 'Created' => timestamp,
311
+ 'Last Activity' => last_activity,
312
+ 'Status' => status_colored
313
+ }
314
+ )
315
+ end
316
+
317
+ def display_learning_status_box(_agent, learning_status, annotations)
318
+ learning_enabled = !annotations.key?(Constants::KubernetesLabels::LEARNING_DISABLED_LABEL)
319
+ status_color = learning_enabled ? :green : :yellow
320
+ status_text = learning_enabled ? 'Enabled' : 'Disabled'
321
+
322
+ # Parse execution summary from ConfigMap if available
323
+ execution_summary = parse_execution_summary(learning_status)
324
+
325
+ if execution_summary
326
+ total_executions = execution_summary['totalExecutions'] || 0
327
+ learning_threshold = execution_summary['learningThreshold'] || 10
328
+ execution_summary['successRate'] || 0.0
329
+ last_execution = execution_summary['lastExecution']
330
+
331
+ runs_processed = "#{total_executions}/#{learning_threshold}"
332
+ progress_percent = [(total_executions.to_f / learning_threshold * 100).round, 100].min
333
+ progress = "#{progress_percent}% toward learning threshold"
334
+
335
+ last_run = if last_execution && !last_execution.empty?
336
+ begin
337
+ Time.parse(last_execution).strftime('%Y-%m-%d %H:%M:%S UTC')
338
+ rescue StandardError
339
+ 'Unknown'
340
+ end
341
+ else
342
+ 'No executions yet'
343
+ end
344
+ else
345
+ runs_processed = 'No data'
346
+ progress = 'Waiting for agent executions'
347
+ last_run = 'No executions yet'
348
+ end
349
+
350
+ highlighted_box(
351
+ title: 'Learning Status',
352
+ color: :cyan,
353
+ rows: {
354
+ 'Learning' => pastel.send(status_color).bold(status_text),
355
+ 'Threshold' => "#{pastel.cyan('10 successful runs')} (auto-learning trigger)",
356
+ 'Confidence Target' => "#{pastel.cyan('85%')} (pattern detection)",
357
+ 'Runs Processed' => runs_processed,
358
+ 'Progress' => progress,
359
+ 'Last Execution' => last_run
360
+ }
361
+ )
362
+ end
363
+
364
+ def display_learning_explanation(learning_enabled)
365
+ if learning_enabled
366
+ puts pastel.dim('Learning is enabled and will begin automatically after the agent completes 10 successful runs.')
367
+ puts pastel.dim('Neural tasks will be analyzed for patterns and converted to symbolic implementations.')
368
+ else
369
+ puts pastel.yellow('Learning is disabled for this agent.')
370
+ puts pastel.dim('Enable learning to allow automatic task optimization after sufficient executions.')
371
+ end
372
+ puts
373
+ puts pastel.dim('Note: Execution metrics will be available once the agent starts running and')
374
+ puts pastel.dim('the operator begins collecting telemetry data.')
375
+ puts
376
+ end
377
+
378
+ def determine_confidence_color(confidence)
379
+ if confidence >= 85
380
+ :green
381
+ elsif confidence >= 70
382
+ :yellow
383
+ else
384
+ :red
385
+ end
386
+ end
387
+
388
+ def parse_execution_summary(learning_status)
389
+ return nil unless learning_status
390
+
391
+ data = learning_status['data']
392
+ return nil unless data
393
+
394
+ execution_summary_json = data['execution-summary']
395
+ return nil unless execution_summary_json
396
+
397
+ begin
398
+ JSON.parse(execution_summary_json)
399
+ rescue StandardError
400
+ nil
401
+ end
402
+ end
284
403
  end
285
404
  end
286
405
  end
@@ -58,8 +58,8 @@ module LanguageOperator
58
58
  unless k8s.namespace_exists?(namespace)
59
59
  Formatters::ProgressFormatter.with_spinner("Creating namespace '#{namespace}'") do
60
60
  k8s.create_namespace(namespace, labels: Constants::KubernetesLabels.cluster_management_labels.merge(
61
- Constants::KubernetesLabels::CLUSTER_LABEL => name
62
- ))
61
+ Constants::KubernetesLabels::CLUSTER_LABEL => name
62
+ ))
63
63
  end
64
64
  end
65
65
 
@@ -35,13 +35,13 @@ module LanguageOperator
35
35
 
36
36
  # Format cluster info using UxHelper
37
37
  logo(title: 'cluster status')
38
-
38
+
39
39
  if cluster_resource
40
40
  # Use actual cluster resource data
41
41
  status = cluster_resource.dig('status', 'phase') || 'Unknown'
42
42
  domain = cluster_resource.dig('spec', 'domain')
43
43
  created = cluster_resource.dig('metadata', 'creationTimestamp')
44
-
44
+
45
45
  format_cluster_details(
46
46
  name: current_cluster,
47
47
  namespace: cluster_config[:namespace],
@@ -55,7 +55,7 @@ module LanguageOperator
55
55
  option :model, type: :string, desc: 'Model to use for synthesis (defaults to first available in cluster)'
56
56
  option :dry_run, type: :boolean, default: false, desc: 'Show prompt without calling LLM'
57
57
  option :raw, type: :boolean, default: false, desc: 'Output only the raw code without formatting'
58
-
58
+
59
59
  def synthesize(instructions = nil)
60
60
  handle_command_error('synthesize agent') do
61
61
  # Read instructions from STDIN if not provided as argument
@@ -51,7 +51,7 @@ module LanguageOperator
51
51
  # ValueFormatter.time_ago(Time.now - 300) # => "5m ago"
52
52
  def self.time_ago(past_time)
53
53
  diff = Time.now - past_time
54
-
54
+
55
55
  if diff < 0
56
56
  'in the future' # Edge case for clock skew
57
57
  elsif diff < SECONDS_PER_MINUTE
@@ -139,8 +139,7 @@ module LanguageOperator
139
139
  # @example
140
140
  # puts highlight_ruby_code("puts 'Hello, world!'")
141
141
  def highlight_ruby_code(code_content)
142
- highlighted = rouge_formatter.format(rouge_lexer.lex(code_content))
143
- highlighted
142
+ rouge_formatter.format(rouge_lexer.lex(code_content))
144
143
  end
145
144
 
146
145
  def logo(title: nil, sparkle: false)
@@ -409,7 +408,7 @@ module LanguageOperator
409
408
  def format_resource_details(type:, name:, common_fields: {}, optional_fields: {})
410
409
  rows = { 'Name' => pastel.white.bold(name) }
411
410
  rows.merge!(common_fields)
412
-
411
+
413
412
  optional_fields.each do |key, value|
414
413
  case key
415
414
  when 'Domain'
@@ -418,7 +417,7 @@ module LanguageOperator
418
417
  rows[key] = value if value
419
418
  end
420
419
  end
421
-
420
+
422
421
  highlighted_box(
423
422
  title: "Language#{type}",
424
423
  rows: rows.compact
@@ -191,7 +191,7 @@ module LanguageOperator
191
191
  # Config.get_int('MAX_WORKERS', default: 4)
192
192
  def self.get_int(*keys, default: nil)
193
193
  keys.each do |key|
194
- value = ENV[key.to_s]
194
+ value = ENV.fetch(key.to_s, nil)
195
195
  next unless value
196
196
 
197
197
  begin
@@ -220,7 +220,7 @@ module LanguageOperator
220
220
  # Config.get_bool('USE_TLS', 'ENABLE_TLS', default: true)
221
221
  def self.get_bool(*keys, default: false)
222
222
  keys.each do |key|
223
- value = ENV[key.to_s]
223
+ value = ENV.fetch(key.to_s, nil)
224
224
  next unless value
225
225
 
226
226
  return %w[true 1 yes on].include?(value.to_s.downcase)
@@ -240,7 +240,7 @@ module LanguageOperator
240
240
  # Config.get_array('ALLOWED_HOSTS', separator: ',')
241
241
  def self.get_array(*keys, default: [], separator: ',')
242
242
  keys.each do |key|
243
- value = ENV[key.to_s]
243
+ value = ENV.fetch(key.to_s, nil)
244
244
  next unless value
245
245
  next if value.empty?
246
246
 
@@ -61,7 +61,7 @@ module LanguageOperator
61
61
  # Build a label selector string for finding tool pods
62
62
  #
63
63
  # @param tool_name [String] The tool name
64
- # @return [String] Label selector string for kubectl commands
64
+ # @return [String] Label selector string for kubectl commands
65
65
  def tool_selector(tool_name)
66
66
  "#{TOOL_LABEL}=#{tool_name}"
67
67
  end
@@ -77,4 +77,4 @@ module LanguageOperator
77
77
  end
78
78
  end
79
79
  end
80
- end
80
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../loggable'
4
4
  require_relative '../type_coercion'
5
+ require_relative '../agent/task_executor'
5
6
 
6
7
  module LanguageOperator
7
8
  module Dsl
@@ -148,7 +149,7 @@ module LanguageOperator
148
149
  #
149
150
  # @param params [Hash] Input parameters
150
151
  # @return [Hash] Validated and coerced parameters
151
- # @raise [ArgumentError] If validation fails
152
+ # @raise [LanguageOperator::Agent::TaskValidationError] If validation fails
152
153
  def validate_inputs(params)
153
154
  params = params.transform_keys(&:to_sym)
154
155
  validated = {}
@@ -157,7 +158,12 @@ module LanguageOperator
157
158
  key_sym = key.to_sym
158
159
  value = params[key_sym]
159
160
 
160
- raise ArgumentError, "Missing required input parameter: #{key}" if value.nil?
161
+ if value.nil?
162
+ original_error = ArgumentError.new("Missing required input parameter: #{key}")
163
+ raise LanguageOperator::Agent::TaskValidationError.new(@name,
164
+ "Missing required input parameter: #{key}",
165
+ original_error)
166
+ end
161
167
 
162
168
  validated[key_sym] = coerce_value(value, type, "input parameter '#{key}'")
163
169
  end
@@ -173,7 +179,7 @@ module LanguageOperator
173
179
  #
174
180
  # @param result [Hash] Output values
175
181
  # @return [Hash] Validated and coerced outputs
176
- # @raise [ArgumentError] If validation fails
182
+ # @raise [LanguageOperator::Agent::TaskValidationError] If validation fails
177
183
  def validate_outputs(result)
178
184
  return result if @outputs_schema.empty? # No schema = no validation
179
185
 
@@ -184,7 +190,12 @@ module LanguageOperator
184
190
  key_sym = key.to_sym
185
191
  value = result[key_sym]
186
192
 
187
- raise ArgumentError, "Missing required output field: #{key}" if value.nil?
193
+ if value.nil?
194
+ original_error = ArgumentError.new("Missing required output field: #{key}")
195
+ raise LanguageOperator::Agent::TaskValidationError.new(@name,
196
+ "Missing required output field: #{key}",
197
+ original_error)
198
+ end
188
199
 
189
200
  validated[key_sym] = coerce_value(value, type, "output field '#{key}'")
190
201
  end
@@ -275,12 +286,12 @@ module LanguageOperator
275
286
  # @param type [String] Target type
276
287
  # @param context [String] Context for error messages
277
288
  # @return [Object] Coerced value
278
- # @raise [ArgumentError] If coercion fails
289
+ # @raise [LanguageOperator::Agent::TaskValidationError] If coercion fails
279
290
  def coerce_value(value, type, context)
280
291
  TypeCoercion.coerce(value, type)
281
292
  rescue ArgumentError => e
282
- # Re-raise with context added
283
- raise ArgumentError, "#{e.message} for #{context}"
293
+ # Re-raise as TaskValidationError with context added
294
+ raise LanguageOperator::Agent::TaskValidationError.new(@name, "#{e.message} for #{context}", e)
284
295
  end
285
296
 
286
297
  # Convert schema hash to JSON Schema format
@@ -87,13 +87,19 @@ module LanguageOperator
87
87
  # @param prompt [String] The generated prompt
88
88
  # @param validated_inputs [Hash] Validated input parameters
89
89
  # @return [Hash] Span attributes
90
- def neural_task_attributes(_task, prompt, validated_inputs)
90
+ def neural_task_attributes(task, prompt, validated_inputs)
91
91
  attributes = {
92
92
  'gen_ai.operation.name' => 'chat',
93
93
  'gen_ai.system' => determine_genai_system,
94
94
  'gen_ai.prompt.size' => prompt.bytesize
95
95
  }
96
96
 
97
+ # Add agent context (CRITICAL for learning system)
98
+ add_agent_context_attributes(attributes)
99
+
100
+ # Add task identification
101
+ attributes['task.name'] = task.name.to_s if task.respond_to?(:name) && task.name
102
+
97
103
  # Add model if available
98
104
  if @agent.respond_to?(:config) && @agent.config
99
105
  model = @agent.config.dig('llm', 'model') || @agent.config['model']
@@ -130,10 +136,19 @@ module LanguageOperator
130
136
  # @param task [TaskDefinition] The task definition
131
137
  # @return [Hash] Span attributes
132
138
  def symbolic_task_attributes(task)
133
- {
139
+ attributes = {
134
140
  'task.execution.type' => 'symbolic',
135
- 'task.execution.has_block' => task.execute_block ? 'true' : 'false'
141
+ 'task.execution.has_block' => task.execute_block ? 'true' : 'false',
142
+ 'gen_ai.operation.name' => 'execute_task'
136
143
  }
144
+
145
+ # Add agent context (CRITICAL for learning system)
146
+ add_agent_context_attributes(attributes)
147
+
148
+ # Add task identification
149
+ attributes['task.name'] = task.name.to_s if task.respond_to?(:name) && task.name
150
+
151
+ attributes
137
152
  end
138
153
 
139
154
  # Record token usage from LLM response on span
@@ -265,6 +280,9 @@ module LanguageOperator
265
280
  'gen_ai.tool.name' => extract_tool_name(tool_call)
266
281
  }
267
282
 
283
+ # Add agent context (CRITICAL for learning system)
284
+ add_agent_context_attributes(attributes)
285
+
268
286
  # Add tool call ID if available
269
287
  attributes['gen_ai.tool.call.id'] = tool_call.id.to_s if tool_call.respond_to?(:id) && tool_call.id
270
288
 
@@ -341,6 +359,29 @@ module LanguageOperator
341
359
  rescue StandardError => e
342
360
  logger&.warn('Failed to record output metadata', error: e.message)
343
361
  end
362
+
363
+ # Add agent context attributes to span attributes hash
364
+ #
365
+ # Ensures all spans include agent identification required for learning system.
366
+ # This is redundant with resource attributes but provides explicit visibility.
367
+ #
368
+ # @param attributes [Hash] Span attributes hash to modify
369
+ def add_agent_context_attributes(attributes)
370
+ # Agent name is CRITICAL for learning controller to track executions
371
+ if (agent_name = ENV.fetch('AGENT_NAME', nil))
372
+ attributes['agent.name'] = agent_name
373
+ end
374
+
375
+ # Add agent mode for better traceability
376
+ if (agent_mode = ENV.fetch('AGENT_MODE', nil))
377
+ attributes['agent.mode'] = agent_mode
378
+ end
379
+
380
+ # Add cluster context if available
381
+ if (cluster_name = ENV.fetch('AGENT_CLUSTER', nil))
382
+ attributes['agent.cluster'] = cluster_name
383
+ end
384
+ end
344
385
  end
345
386
  # rubocop:enable Metrics/ModuleLength
346
387
  end
@@ -3,6 +3,7 @@
3
3
  require 'k8s-ruby'
4
4
  require 'yaml'
5
5
  require_relative '../utils/secure_path'
6
+ require_relative '../agent/event_config'
6
7
 
7
8
  module LanguageOperator
8
9
  module Kubernetes
@@ -163,6 +164,72 @@ module LanguageOperator
163
164
  create_resource(resource)
164
165
  end
165
166
 
167
+ # Create a Kubernetes Event
168
+ # @param event [Hash] Event resource hash
169
+ # @return [K8s::Resource] Created event resource
170
+ def create_event(event)
171
+ # Ensure proper apiVersion and kind for events
172
+ event = event.merge({
173
+ 'apiVersion' => 'v1',
174
+ 'kind' => 'Event'
175
+ })
176
+ create_resource(event)
177
+ end
178
+
179
+ # Emit an execution event for agent task completion
180
+ # @param task_name [String] Name of the executed task
181
+ # @param success [Boolean] Whether task succeeded
182
+ # @param duration_ms [Float] Execution duration in milliseconds
183
+ # @param metadata [Hash] Additional metadata to include
184
+ # @return [K8s::Resource, nil] Created event resource or nil if disabled
185
+ def emit_execution_event(task_name, success:, duration_ms:, metadata: {})
186
+ # Check if events are enabled based on configuration
187
+ event_config = Agent::EventConfig.load
188
+ event_type = success ? :success : :failure
189
+ return nil unless Agent::EventConfig.should_emit?(event_type, event_config)
190
+
191
+ agent_name = ENV.fetch('AGENT_NAME', nil)
192
+ agent_namespace = ENV.fetch('AGENT_NAMESPACE', current_namespace)
193
+
194
+ return nil unless agent_name && agent_namespace
195
+
196
+ timestamp = Time.now.to_i
197
+ event_name = "#{agent_name}-task-#{task_name}-#{timestamp}"
198
+
199
+ event = {
200
+ 'metadata' => {
201
+ 'name' => event_name,
202
+ 'namespace' => agent_namespace,
203
+ 'labels' => {
204
+ 'app.kubernetes.io/name' => 'language-operator',
205
+ 'app.kubernetes.io/component' => 'agent',
206
+ 'langop.io/agent-name' => agent_name,
207
+ 'langop.io/task-name' => task_name.to_s
208
+ }
209
+ },
210
+ 'involvedObject' => {
211
+ 'kind' => 'LanguageAgent',
212
+ 'name' => agent_name,
213
+ 'namespace' => agent_namespace,
214
+ 'apiVersion' => 'langop.io/v1alpha1'
215
+ },
216
+ 'reason' => success ? 'TaskCompleted' : 'TaskFailed',
217
+ 'message' => build_event_message(task_name, success, duration_ms, metadata, event_config),
218
+ 'type' => success ? 'Normal' : 'Warning',
219
+ 'firstTimestamp' => Time.now.utc.iso8601,
220
+ 'lastTimestamp' => Time.now.utc.iso8601,
221
+ 'source' => {
222
+ 'component' => 'language-operator-agent'
223
+ }
224
+ }
225
+
226
+ create_event(event)
227
+ rescue StandardError => e
228
+ # Log error but don't fail task execution
229
+ warn "Failed to emit execution event: #{e.message}"
230
+ nil
231
+ end
232
+
166
233
  # Check if operator is installed
167
234
  def operator_installed?
168
235
  # Check if LanguageCluster CRD exists
@@ -184,6 +251,48 @@ module LanguageOperator
184
251
 
185
252
  private
186
253
 
254
+ # Build event message for task execution
255
+ # @param task_name [String] Task name
256
+ # @param success [Boolean] Whether task succeeded
257
+ # @param duration_ms [Float] Execution duration in milliseconds
258
+ # @param metadata [Hash] Additional metadata
259
+ # @param event_config [Hash] Event configuration
260
+ # @return [String] Formatted event message
261
+ def build_event_message(task_name, success, duration_ms, metadata = {}, event_config = nil)
262
+ event_config ||= Agent::EventConfig.load
263
+ content_config = Agent::EventConfig.content_config(event_config)
264
+
265
+ status = success ? 'completed successfully' : 'failed'
266
+ message = "Task '#{task_name}' #{status} in #{duration_ms.round(2)}ms"
267
+
268
+ # Include metadata if configured
269
+ if content_config[:include_task_metadata] && metadata.any?
270
+ # Filter metadata based on configuration
271
+ filtered_metadata = metadata.dup
272
+
273
+ # Remove error details if not configured to include them
274
+ unless content_config[:include_error_details]
275
+ filtered_metadata.delete('error_type')
276
+ filtered_metadata.delete('error_category')
277
+ end
278
+
279
+ if filtered_metadata.any?
280
+ details = filtered_metadata.map { |k, v| "#{k}: #{v}" }.join(', ')
281
+ message += " (#{details})"
282
+ end
283
+ end
284
+
285
+ # Truncate if configured and message is too long
286
+ if content_config[:truncate_long_messages] &&
287
+ message.length > content_config[:max_message_length]
288
+ max_length = content_config[:max_message_length]
289
+ truncated_length = message.length - max_length
290
+ message = message[0...max_length] + "... (truncated #{truncated_length} chars)"
291
+ end
292
+
293
+ message
294
+ end
295
+
187
296
  # Build singleton instance with automatic config detection
188
297
  def self.build_singleton
189
298
  if in_cluster?
@@ -266,6 +375,8 @@ module LanguageOperator
266
375
  'statefulsets'
267
376
  when 'cronjob'
268
377
  'cronjobs'
378
+ when 'event'
379
+ 'events'
269
380
  else
270
381
  # Generic pluralization - add 's'
271
382
  "#{kind.downcase}s"