language-operator 0.0.1 → 0.1.30

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +125 -0
  3. data/CHANGELOG.md +53 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +284 -0
  6. data/LICENSE +229 -21
  7. data/Makefile +77 -0
  8. data/README.md +3 -11
  9. data/Rakefile +34 -0
  10. data/bin/aictl +7 -0
  11. data/completions/_aictl +232 -0
  12. data/completions/aictl.bash +121 -0
  13. data/completions/aictl.fish +114 -0
  14. data/docs/architecture/agent-runtime.md +585 -0
  15. data/docs/dsl/agent-reference.md +591 -0
  16. data/docs/dsl/best-practices.md +1078 -0
  17. data/docs/dsl/chat-endpoints.md +895 -0
  18. data/docs/dsl/constraints.md +671 -0
  19. data/docs/dsl/mcp-integration.md +1177 -0
  20. data/docs/dsl/webhooks.md +932 -0
  21. data/docs/dsl/workflows.md +744 -0
  22. data/examples/README.md +569 -0
  23. data/examples/agent_example.rb +86 -0
  24. data/examples/chat_endpoint_agent.rb +118 -0
  25. data/examples/github_webhook_agent.rb +171 -0
  26. data/examples/mcp_agent.rb +158 -0
  27. data/examples/oauth_callback_agent.rb +296 -0
  28. data/examples/stripe_webhook_agent.rb +219 -0
  29. data/examples/webhook_agent.rb +80 -0
  30. data/lib/language_operator/agent/base.rb +110 -0
  31. data/lib/language_operator/agent/executor.rb +440 -0
  32. data/lib/language_operator/agent/instrumentation.rb +54 -0
  33. data/lib/language_operator/agent/metrics_tracker.rb +183 -0
  34. data/lib/language_operator/agent/safety/ast_validator.rb +272 -0
  35. data/lib/language_operator/agent/safety/audit_logger.rb +104 -0
  36. data/lib/language_operator/agent/safety/budget_tracker.rb +175 -0
  37. data/lib/language_operator/agent/safety/content_filter.rb +93 -0
  38. data/lib/language_operator/agent/safety/manager.rb +207 -0
  39. data/lib/language_operator/agent/safety/rate_limiter.rb +150 -0
  40. data/lib/language_operator/agent/safety/safe_executor.rb +115 -0
  41. data/lib/language_operator/agent/scheduler.rb +183 -0
  42. data/lib/language_operator/agent/telemetry.rb +116 -0
  43. data/lib/language_operator/agent/web_server.rb +610 -0
  44. data/lib/language_operator/agent/webhook_authenticator.rb +226 -0
  45. data/lib/language_operator/agent.rb +149 -0
  46. data/lib/language_operator/cli/commands/agent.rb +1252 -0
  47. data/lib/language_operator/cli/commands/cluster.rb +335 -0
  48. data/lib/language_operator/cli/commands/install.rb +404 -0
  49. data/lib/language_operator/cli/commands/model.rb +266 -0
  50. data/lib/language_operator/cli/commands/persona.rb +396 -0
  51. data/lib/language_operator/cli/commands/quickstart.rb +22 -0
  52. data/lib/language_operator/cli/commands/status.rb +156 -0
  53. data/lib/language_operator/cli/commands/tool.rb +537 -0
  54. data/lib/language_operator/cli/commands/use.rb +47 -0
  55. data/lib/language_operator/cli/errors/handler.rb +180 -0
  56. data/lib/language_operator/cli/errors/suggestions.rb +176 -0
  57. data/lib/language_operator/cli/formatters/code_formatter.rb +81 -0
  58. data/lib/language_operator/cli/formatters/log_formatter.rb +290 -0
  59. data/lib/language_operator/cli/formatters/progress_formatter.rb +53 -0
  60. data/lib/language_operator/cli/formatters/table_formatter.rb +179 -0
  61. data/lib/language_operator/cli/formatters/value_formatter.rb +113 -0
  62. data/lib/language_operator/cli/helpers/cluster_context.rb +62 -0
  63. data/lib/language_operator/cli/helpers/cluster_validator.rb +101 -0
  64. data/lib/language_operator/cli/helpers/editor_helper.rb +58 -0
  65. data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +167 -0
  66. data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +74 -0
  67. data/lib/language_operator/cli/helpers/schedule_builder.rb +108 -0
  68. data/lib/language_operator/cli/helpers/user_prompts.rb +69 -0
  69. data/lib/language_operator/cli/main.rb +232 -0
  70. data/lib/language_operator/cli/templates/tools/generic.yaml +66 -0
  71. data/lib/language_operator/cli/wizards/agent_wizard.rb +246 -0
  72. data/lib/language_operator/cli/wizards/quickstart_wizard.rb +588 -0
  73. data/lib/language_operator/client/base.rb +214 -0
  74. data/lib/language_operator/client/config.rb +136 -0
  75. data/lib/language_operator/client/cost_calculator.rb +37 -0
  76. data/lib/language_operator/client/mcp_connector.rb +123 -0
  77. data/lib/language_operator/client.rb +19 -0
  78. data/lib/language_operator/config/cluster_config.rb +101 -0
  79. data/lib/language_operator/config/tool_patterns.yaml +57 -0
  80. data/lib/language_operator/config/tool_registry.rb +96 -0
  81. data/lib/language_operator/config.rb +138 -0
  82. data/lib/language_operator/dsl/adapter.rb +124 -0
  83. data/lib/language_operator/dsl/agent_context.rb +90 -0
  84. data/lib/language_operator/dsl/agent_definition.rb +427 -0
  85. data/lib/language_operator/dsl/chat_endpoint_definition.rb +115 -0
  86. data/lib/language_operator/dsl/config.rb +119 -0
  87. data/lib/language_operator/dsl/context.rb +50 -0
  88. data/lib/language_operator/dsl/execution_context.rb +47 -0
  89. data/lib/language_operator/dsl/helpers.rb +109 -0
  90. data/lib/language_operator/dsl/http.rb +184 -0
  91. data/lib/language_operator/dsl/mcp_server_definition.rb +73 -0
  92. data/lib/language_operator/dsl/parameter_definition.rb +124 -0
  93. data/lib/language_operator/dsl/registry.rb +36 -0
  94. data/lib/language_operator/dsl/shell.rb +125 -0
  95. data/lib/language_operator/dsl/tool_definition.rb +112 -0
  96. data/lib/language_operator/dsl/webhook_authentication.rb +114 -0
  97. data/lib/language_operator/dsl/webhook_definition.rb +106 -0
  98. data/lib/language_operator/dsl/workflow_definition.rb +259 -0
  99. data/lib/language_operator/dsl.rb +160 -0
  100. data/lib/language_operator/errors.rb +60 -0
  101. data/lib/language_operator/kubernetes/client.rb +279 -0
  102. data/lib/language_operator/kubernetes/resource_builder.rb +194 -0
  103. data/lib/language_operator/loggable.rb +47 -0
  104. data/lib/language_operator/logger.rb +141 -0
  105. data/lib/language_operator/retry.rb +123 -0
  106. data/lib/language_operator/retryable.rb +132 -0
  107. data/lib/language_operator/tool_loader.rb +242 -0
  108. data/lib/language_operator/validators.rb +170 -0
  109. data/lib/language_operator/version.rb +1 -1
  110. data/lib/language_operator.rb +65 -3
  111. data/requirements/tasks/challenge.md +9 -0
  112. data/requirements/tasks/iterate.md +36 -0
  113. data/requirements/tasks/optimize.md +21 -0
  114. data/requirements/tasks/tag.md +5 -0
  115. data/test_agent_dsl.rb +108 -0
  116. metadata +503 -20
@@ -0,0 +1,440 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../logger'
4
+ require_relative '../loggable'
5
+ require_relative 'metrics_tracker'
6
+ require_relative 'safety/manager'
7
+ require_relative 'instrumentation'
8
+
9
+ module LanguageOperator
10
+ module Agent
11
+ # Task Executor
12
+ #
13
+ # Handles autonomous task execution with retry logic and error handling.
14
+ #
15
+ # @example
16
+ # executor = Executor.new(agent)
17
+ # executor.execute("Complete the task")
18
+ class Executor
19
+ include LanguageOperator::Loggable
20
+ include Instrumentation
21
+
22
+ attr_reader :agent, :iteration_count, :metrics_tracker
23
+
24
+ # Initialize the executor
25
+ #
26
+ # @param agent [LanguageOperator::Agent::Base] The agent instance
27
+ # @param agent_definition [LanguageOperator::Dsl::AgentDefinition, nil] Optional agent definition
28
+ def initialize(agent, agent_definition: nil)
29
+ @agent = agent
30
+ @agent_definition = agent_definition
31
+ @iteration_count = 0
32
+ @max_iterations = 100
33
+ @show_full_responses = ENV.fetch('SHOW_FULL_RESPONSES', 'false') == 'true'
34
+ @metrics_tracker = MetricsTracker.new
35
+
36
+ # Initialize safety manager from agent definition or environment
37
+ @safety_manager = initialize_safety_manager(agent_definition)
38
+
39
+ logger.debug('Executor initialized',
40
+ max_iterations: @max_iterations,
41
+ show_full_responses: @show_full_responses,
42
+ workspace: @agent.workspace_path,
43
+ safety_enabled: @safety_manager&.enabled?)
44
+ end
45
+
46
+ # Execute a task with additional context (for webhooks/HTTP requests)
47
+ #
48
+ # @param instruction [String] The instruction to execute
49
+ # @param context [Hash] Additional context (webhook payload, request data, etc.)
50
+ # @return [String] The result
51
+ def execute_with_context(instruction:, context: {})
52
+ # Build enriched instruction with context
53
+ enriched_instruction = build_instruction_with_context(instruction, context)
54
+
55
+ # Execute with standard logic
56
+ execute(enriched_instruction)
57
+ end
58
+
59
+ # Execute a single task or workflow
60
+ #
61
+ # @param task [String] The task to execute
62
+ # @param agent_definition [LanguageOperator::Dsl::AgentDefinition, nil] Optional agent definition with workflow
63
+ # @return [String] The result
64
+ # rubocop:disable Metrics/BlockLength
65
+ def execute(task, agent_definition: nil)
66
+ with_span('agent.execute_goal', attributes: {
67
+ 'agent.goal_description' => task[0...500]
68
+ }) do
69
+ @iteration_count += 1
70
+
71
+ # Route to workflow execution if agent has a workflow defined
72
+ return execute_workflow(agent_definition) if agent_definition&.workflow
73
+
74
+ # Standard instruction-based execution
75
+ logger.info('Starting iteration',
76
+ iteration: @iteration_count,
77
+ max_iterations: @max_iterations)
78
+ logger.debug('Prompt', prompt: task[0..200])
79
+
80
+ # Safety check before request
81
+ if @safety_manager&.enabled?
82
+ # Estimate cost and tokens (rough estimate)
83
+ estimated_tokens = estimate_tokens(task)
84
+ estimated_cost = estimate_cost(estimated_tokens)
85
+
86
+ @safety_manager.check_request!(
87
+ message: task,
88
+ estimated_cost: estimated_cost,
89
+ estimated_tokens: estimated_tokens
90
+ )
91
+ end
92
+
93
+ logger.info('🤖 LLM request')
94
+ result = logger.timed('LLM response received') do
95
+ @agent.send_message(task)
96
+ end
97
+
98
+ # Record metrics
99
+ model_id = @agent.config.dig('llm', 'model')
100
+ @metrics_tracker.record_request(result, model_id) if model_id
101
+
102
+ # Safety check after response and record spending
103
+ result_text = result.is_a?(String) ? result : result.content
104
+ metrics = @metrics_tracker.cumulative_stats
105
+
106
+ if @safety_manager&.enabled?
107
+ @safety_manager.check_response!(result_text)
108
+ @safety_manager.record_request(
109
+ cost: metrics[:estimatedCost],
110
+ tokens: metrics[:totalTokens]
111
+ )
112
+ end
113
+ logger.info('✓ Iteration completed',
114
+ iteration: @iteration_count,
115
+ response_length: result_text.length,
116
+ total_tokens: metrics[:totalTokens],
117
+ estimated_cost: "$#{metrics[:estimatedCost]}")
118
+ logger.debug('Response preview', response: result_text[0..200])
119
+
120
+ result
121
+ rescue StandardError => e
122
+ handle_error(e)
123
+ end
124
+ end
125
+ # rubocop:enable Metrics/BlockLength
126
+
127
+ # Run continuous execution loop
128
+ #
129
+ # @return [void]
130
+ def run_loop
131
+ start_time = Time.now
132
+
133
+ logger.info('▶ Starting execution')
134
+ logger.info('Configuration',
135
+ workspace: @agent.workspace_path,
136
+ mcp_servers: @agent.servers_info.length,
137
+ max_iterations: @max_iterations)
138
+
139
+ # Log persona loading
140
+ persona = @agent.config.dig('agent', 'persona') || 'default'
141
+ logger.info("👤 Loading persona: #{persona}")
142
+
143
+ # Log MCP server details
144
+ if @agent.servers_info.any?
145
+ @agent.servers_info.each do |server|
146
+ logger.info('◆ MCP server connected', name: server[:name], tool_count: server[:tool_count])
147
+ end
148
+ end
149
+
150
+ # Get initial instructions from config or environment
151
+ instructions = @agent.config.dig('agent', 'instructions') ||
152
+ ENV['AGENT_INSTRUCTIONS'] ||
153
+ 'Monitor workspace and respond to changes'
154
+
155
+ logger.info('Instructions', instructions: instructions[0..200])
156
+ logger.info('Starting autonomous execution loop')
157
+
158
+ loop do
159
+ break if @iteration_count >= @max_iterations
160
+
161
+ progress_pct = ((@iteration_count.to_f / @max_iterations) * 100).round(1)
162
+ logger.debug('Loop progress',
163
+ iteration: @iteration_count,
164
+ max: @max_iterations,
165
+ progress: "#{progress_pct}%")
166
+
167
+ result = execute(instructions)
168
+ result_text = result.is_a?(String) ? result : result.content
169
+
170
+ # Log result based on verbosity settings
171
+ if @show_full_responses
172
+ logger.info('Full iteration result',
173
+ iteration: @iteration_count,
174
+ result: result_text)
175
+ else
176
+ preview = result_text[0..200]
177
+ preview += '...' if result_text.length > 200
178
+ logger.info('Iteration result',
179
+ iteration: @iteration_count,
180
+ preview: preview)
181
+ end
182
+
183
+ # Rate limiting
184
+ logger.debug('Rate limit pause', duration: 5)
185
+ sleep 5
186
+ end
187
+
188
+ # Log execution summary
189
+ total_duration = Time.now - start_time
190
+ metrics = @metrics_tracker.cumulative_stats
191
+ logger.info('✅ Execution complete',
192
+ iterations: @iteration_count,
193
+ duration_s: total_duration.round(2),
194
+ total_requests: metrics[:requestCount],
195
+ total_tokens: metrics[:totalTokens],
196
+ estimated_cost: "$#{metrics[:estimatedCost]}",
197
+ reason: @iteration_count >= @max_iterations ? 'max_iterations' : 'completed')
198
+
199
+ return unless @iteration_count >= @max_iterations
200
+
201
+ logger.warn('Maximum iterations reached',
202
+ iterations: @max_iterations,
203
+ reason: 'Hit max_iterations limit')
204
+ end
205
+
206
+ # Execute a workflow-based agent
207
+ #
208
+ # @param agent_def [LanguageOperator::Dsl::AgentDefinition] The agent definition
209
+ # @return [RubyLLM::Message] The final response
210
+ def execute_workflow(agent_def)
211
+ start_time = Time.now
212
+
213
+ logger.info("▶ Starting workflow execution: #{agent_def.name}")
214
+
215
+ # Log persona if defined
216
+ logger.info("👤 Loading persona: #{agent_def.persona}") if agent_def.persona
217
+
218
+ # Build orchestration prompt from agent definition
219
+ prompt = build_workflow_prompt(agent_def)
220
+ logger.debug('Workflow prompt', prompt: prompt[0..300])
221
+
222
+ # Register workflow steps as tools (placeholder - will implement after tool converter)
223
+ # For now, just execute with instructions
224
+ result = logger.timed('🤖 LLM request') do
225
+ @agent.send_message(prompt)
226
+ end
227
+
228
+ # Record metrics
229
+ model_id = @agent.config.dig('llm', 'model')
230
+ @metrics_tracker.record_request(result, model_id) if model_id
231
+
232
+ # Write output if configured
233
+ write_output(agent_def, result) if agent_def.output_config && result
234
+
235
+ # Log execution summary
236
+ total_duration = Time.now - start_time
237
+ metrics = @metrics_tracker.cumulative_stats
238
+ logger.info('✅ Workflow execution completed',
239
+ duration_s: total_duration.round(2),
240
+ total_tokens: metrics[:totalTokens],
241
+ estimated_cost: "$#{metrics[:estimatedCost]}")
242
+ result
243
+ rescue StandardError => e
244
+ logger.error('❌ Workflow execution failed', error: e.message)
245
+ handle_error(e)
246
+ end
247
+
248
+ # Build orchestration prompt from agent definition
249
+ #
250
+ # @param agent_def [LanguageOperator::Dsl::AgentDefinition] The agent definition
251
+ # @return [String] The prompt
252
+ def build_workflow_prompt(agent_def)
253
+ prompt = "# Task: #{agent_def.description}\n\n"
254
+
255
+ if agent_def.objectives&.any?
256
+ prompt += "## Objectives:\n"
257
+ agent_def.objectives.each { |obj| prompt += "- #{obj}\n" }
258
+ prompt += "\n"
259
+ end
260
+
261
+ if agent_def.workflow&.steps&.any?
262
+ prompt += "## Workflow Steps:\n"
263
+ agent_def.workflow.step_order.each do |step_name|
264
+ step = agent_def.workflow.steps[step_name]
265
+ prompt += step_name.to_s.tr('_', ' ').capitalize.to_s
266
+ prompt += " (using tool: #{step.tool_name})" if step.tool_name
267
+ prompt += " - depends on: #{step.dependencies.join(', ')}" if step.dependencies&.any?
268
+ prompt += "\n"
269
+ end
270
+ prompt += "\n"
271
+ end
272
+
273
+ if agent_def.constraints
274
+ prompt += "## Constraints:\n"
275
+ prompt += "- Maximum iterations: #{agent_def.constraints[:max_iterations]}\n" if agent_def.constraints[:max_iterations]
276
+ prompt += "- Timeout: #{agent_def.constraints[:timeout]}\n" if agent_def.constraints[:timeout]
277
+ prompt += "\n"
278
+ end
279
+
280
+ prompt += 'Please complete this task following the workflow steps.'
281
+ prompt
282
+ end
283
+
284
+ # Write output to configured destinations
285
+ #
286
+ # @param agent_def [LanguageOperator::Dsl::AgentDefinition] The agent definition
287
+ # @param result [RubyLLM::Message] The result to write
288
+ def write_output(agent_def, result)
289
+ return unless agent_def.output_config
290
+
291
+ content = result.is_a?(String) ? result : result.content
292
+
293
+ if (workspace_path = agent_def.output_config[:workspace])
294
+ full_path = File.join(@agent.workspace_path, workspace_path)
295
+
296
+ begin
297
+ FileUtils.mkdir_p(File.dirname(full_path))
298
+ File.write(full_path, content)
299
+ logger.info("📝 Wrote output to #{workspace_path}")
300
+ rescue Errno::EACCES, Errno::EPERM
301
+ # Permission denied - try writing to workspace root
302
+ fallback_path = File.join(@agent.workspace_path, 'output.txt')
303
+ begin
304
+ File.write(fallback_path, content)
305
+ logger.warn("⚠️ Could not write to #{workspace_path}, wrote to output.txt instead")
306
+ rescue StandardError => e2
307
+ logger.warn("⚠️ Could not write output to workspace: #{e2.message}")
308
+ logger.info("📄 Output (first 500 chars): #{content[0..500]}")
309
+ end
310
+ end
311
+ end
312
+
313
+ # Future: Handle Slack, email outputs
314
+ rescue StandardError => e
315
+ logger.warn('Output writing failed', error: e.message)
316
+ end
317
+
318
+ private
319
+
320
+ def logger_component
321
+ 'Agent::Executor'
322
+ end
323
+
324
+ # Build instruction enriched with request context
325
+ #
326
+ # @param instruction [String] Base instruction
327
+ # @param context [Hash] Request context
328
+ # @return [String] Enriched instruction
329
+ def build_instruction_with_context(instruction, context)
330
+ enriched = instruction.dup
331
+ enriched += "\n\n## Request Context\n"
332
+ enriched += "- Method: #{context[:method]}\n" if context[:method]
333
+ enriched += "- Path: #{context[:path]}\n" if context[:path]
334
+
335
+ if context[:params] && !context[:params].empty?
336
+ enriched += "\n### Parameters:\n"
337
+ enriched += "```json\n#{JSON.pretty_generate(context[:params])}\n```\n"
338
+ end
339
+
340
+ if context[:body] && !context[:body].empty?
341
+ enriched += "\n### Request Body:\n"
342
+ enriched += "```\n#{context[:body][0..1000]}\n```\n"
343
+ end
344
+
345
+ if context[:headers] && !context[:headers].empty?
346
+ enriched += "\n### Headers:\n"
347
+ context[:headers].each do |key, value|
348
+ enriched += "- #{key}: #{value}\n"
349
+ end
350
+ end
351
+
352
+ enriched
353
+ end
354
+
355
+ def initialize_safety_manager(agent_definition)
356
+ # Get safety config from agent definition constraints
357
+ config = agent_definition&.constraints || {}
358
+
359
+ # Merge with environment variables
360
+ config = {
361
+ enabled: ENV.fetch('SAFETY_ENABLED', 'true') != 'false',
362
+ daily_budget: config[:daily_budget] || parse_float_env('DAILY_BUDGET'),
363
+ hourly_budget: config[:hourly_budget] || parse_float_env('HOURLY_BUDGET'),
364
+ token_budget: config[:token_budget] || parse_int_env('TOKEN_BUDGET'),
365
+ requests_per_minute: config[:requests_per_minute] || parse_int_env('REQUESTS_PER_MINUTE'),
366
+ requests_per_hour: config[:requests_per_hour] || parse_int_env('REQUESTS_PER_HOUR'),
367
+ requests_per_day: config[:requests_per_day] || parse_int_env('REQUESTS_PER_DAY'),
368
+ blocked_patterns: config[:blocked_patterns] || parse_array_env('BLOCKED_PATTERNS'),
369
+ blocked_topics: config[:blocked_topics] || parse_array_env('BLOCKED_TOPICS'),
370
+ case_sensitive: config[:case_sensitive] || ENV.fetch('CASE_SENSITIVE', 'false') == 'true',
371
+ audit_logging: config[:audit_logging] != false
372
+ }.compact
373
+
374
+ return nil if config[:enabled] == false
375
+
376
+ Safety::Manager.new(config)
377
+ rescue StandardError => e
378
+ logger.warn('Failed to initialize safety manager',
379
+ error: e.message,
380
+ fallback: 'Safety features disabled')
381
+ nil
382
+ end
383
+
384
+ def parse_float_env(key)
385
+ val = ENV.fetch(key, nil)
386
+ return nil unless val
387
+
388
+ val.to_f
389
+ end
390
+
391
+ def parse_int_env(key)
392
+ val = ENV.fetch(key, nil)
393
+ return nil unless val
394
+
395
+ val.to_i
396
+ end
397
+
398
+ def parse_array_env(key)
399
+ val = ENV.fetch(key, nil)
400
+ return nil unless val
401
+
402
+ val.split(',').map(&:strip)
403
+ end
404
+
405
+ def estimate_tokens(text)
406
+ # Rough estimate: ~1.3 tokens per word
407
+ (text.split.length * 1.3).to_i
408
+ end
409
+
410
+ def estimate_cost(tokens)
411
+ # Estimate based on common model pricing
412
+ # Average of ~$3-15 per 1M tokens (using $10 as middle ground)
413
+ # This is a rough estimate; actual cost varies by model
414
+ (tokens / 1_000_000.0) * 10.0
415
+ end
416
+
417
+ def handle_error(error)
418
+ case error
419
+ when Timeout::Error, /timeout/i.match?(error.message)
420
+ logger.error('Request timeout',
421
+ error: error.class.name,
422
+ message: error.message,
423
+ iteration: @iteration_count)
424
+ when /connection refused|operation not permitted/i.match?(error.message)
425
+ logger.error('Connection failed',
426
+ error: error.class.name,
427
+ message: error.message,
428
+ hint: 'Check if model service is healthy and accessible')
429
+ else
430
+ logger.error('Task execution failed',
431
+ error: error.class.name,
432
+ message: error.message)
433
+ logger.debug('Backtrace', trace: error.backtrace[0..5].join("\n")) if error.backtrace
434
+ end
435
+
436
+ "Error executing task: #{error.message}"
437
+ end
438
+ end
439
+ end
440
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry/sdk'
4
+
5
+ module LanguageOperator
6
+ module Agent
7
+ # OpenTelemetry instrumentation helpers for agent methods
8
+ #
9
+ # Provides reusable patterns for tracing agent operations with
10
+ # automatic error handling and span status management.
11
+ #
12
+ # @example Instrument a method
13
+ # include LanguageOperator::Agent::Instrumentation
14
+ #
15
+ # def my_method
16
+ # with_span('my_method', attributes: { 'key' => 'value' }) do
17
+ # # Method implementation
18
+ # end
19
+ # end
20
+ module Instrumentation
21
+ private
22
+
23
+ # Get the configured OpenTelemetry tracer
24
+ #
25
+ # @return [OpenTelemetry::Trace::Tracer]
26
+ def tracer
27
+ @tracer ||= OpenTelemetry.tracer_provider.tracer(
28
+ 'language-operator-agent',
29
+ LanguageOperator::VERSION
30
+ )
31
+ end
32
+
33
+ # Execute block within a traced span with automatic error handling
34
+ #
35
+ # Creates a span with the given name and attributes, executes the block,
36
+ # and automatically records exceptions and sets error status if raised.
37
+ #
38
+ # @param name [String] Span name
39
+ # @param attributes [Hash] Span attributes
40
+ # @yield [OpenTelemetry::Trace::Span] The created span
41
+ # @return [Object] Result of the block
42
+ # @raise Re-raises any exception after recording it on the span
43
+ def with_span(name, attributes: {})
44
+ tracer.in_span(name, attributes: attributes) do |span|
45
+ yield span
46
+ rescue StandardError => e
47
+ span.record_exception(e)
48
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
49
+ raise
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LanguageOperator
4
+ module Agent
5
+ class MetricsTracker
6
+ attr_reader :metrics
7
+
8
+ def initialize
9
+ @mutex = Mutex.new
10
+ @metrics = {
11
+ total_input_tokens: 0,
12
+ total_output_tokens: 0,
13
+ total_cached_tokens: 0,
14
+ total_cache_creation_tokens: 0,
15
+ request_count: 0,
16
+ total_cost: 0.0,
17
+ requests: []
18
+ }
19
+ @pricing_cache = {}
20
+ end
21
+
22
+ # Record token usage from an LLM response
23
+ # @param response [Object] RubyLLM response object
24
+ # @param model_id [String] Model identifier
25
+ def record_request(response, model_id)
26
+ return unless response
27
+
28
+ @mutex.synchronize do
29
+ # Extract token counts with defensive checks
30
+ input_tokens = extract_token_count(response, :input_tokens)
31
+ output_tokens = extract_token_count(response, :output_tokens)
32
+ cached_tokens = extract_token_count(response, :cached_tokens)
33
+ cache_creation_tokens = extract_token_count(response, :cache_creation_tokens)
34
+
35
+ # Calculate cost for this request
36
+ cost = calculate_cost(input_tokens, output_tokens, model_id)
37
+
38
+ # Update cumulative metrics
39
+ @metrics[:total_input_tokens] += input_tokens
40
+ @metrics[:total_output_tokens] += output_tokens
41
+ @metrics[:total_cached_tokens] += cached_tokens
42
+ @metrics[:total_cache_creation_tokens] += cache_creation_tokens
43
+ @metrics[:request_count] += 1
44
+ @metrics[:total_cost] += cost
45
+
46
+ # Store per-request history (limited to last 100 requests)
47
+ @metrics[:requests] << {
48
+ timestamp: Time.now.iso8601,
49
+ model: model_id,
50
+ input_tokens: input_tokens,
51
+ output_tokens: output_tokens,
52
+ cached_tokens: cached_tokens,
53
+ cache_creation_tokens: cache_creation_tokens,
54
+ cost: cost.round(6)
55
+ }
56
+ @metrics[:requests].shift if @metrics[:requests].size > 100
57
+ end
58
+ end
59
+
60
+ # Get cumulative statistics
61
+ # @return [Hash] Hash with totalTokens, estimatedCost, requestCount
62
+ def cumulative_stats
63
+ @mutex.synchronize do
64
+ {
65
+ totalTokens: @metrics[:total_input_tokens] + @metrics[:total_output_tokens],
66
+ inputTokens: @metrics[:total_input_tokens],
67
+ outputTokens: @metrics[:total_output_tokens],
68
+ cachedTokens: @metrics[:total_cached_tokens],
69
+ cacheCreationTokens: @metrics[:total_cache_creation_tokens],
70
+ requestCount: @metrics[:request_count],
71
+ estimatedCost: @metrics[:total_cost].round(6)
72
+ }
73
+ end
74
+ end
75
+
76
+ # Get recent request history
77
+ # @param limit [Integer] Number of recent requests to return
78
+ # @return [Array<Hash>] Array of request details
79
+ def recent_requests(limit = 10)
80
+ @mutex.synchronize do
81
+ @metrics[:requests].last(limit)
82
+ end
83
+ end
84
+
85
+ # Reset all metrics (for testing)
86
+ def reset!
87
+ @mutex.synchronize do
88
+ @metrics = {
89
+ total_input_tokens: 0,
90
+ total_output_tokens: 0,
91
+ total_cached_tokens: 0,
92
+ total_cache_creation_tokens: 0,
93
+ request_count: 0,
94
+ total_cost: 0.0,
95
+ requests: []
96
+ }
97
+ @pricing_cache = {}
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ # Extract token count from response with defensive checks
104
+ # @param response [Object] Response object
105
+ # @param method [Symbol] Method name to call
106
+ # @return [Integer] Token count or 0 if unavailable
107
+ def extract_token_count(response, method)
108
+ return 0 unless response.respond_to?(method)
109
+
110
+ value = response.public_send(method)
111
+ value.is_a?(Integer) ? value : 0
112
+ rescue StandardError
113
+ 0
114
+ end
115
+
116
+ # Calculate cost based on token usage and model pricing
117
+ # @param input_tokens [Integer] Number of input tokens
118
+ # @param output_tokens [Integer] Number of output tokens
119
+ # @param model_id [String] Model identifier
120
+ # @return [Float] Estimated cost in USD
121
+ def calculate_cost(input_tokens, output_tokens, model_id)
122
+ pricing = get_pricing(model_id)
123
+ return 0.0 unless pricing
124
+
125
+ input_cost = (input_tokens / 1_000_000.0) * pricing[:input]
126
+ output_cost = (output_tokens / 1_000_000.0) * pricing[:output]
127
+ input_cost + output_cost
128
+ rescue StandardError => e
129
+ LanguageOperator.logger.warn('Cost calculation failed',
130
+ model: model_id,
131
+ error: e.message)
132
+ 0.0
133
+ end
134
+
135
+ # Get pricing for a model (with caching)
136
+ # @param model_id [String] Model identifier
137
+ # @return [Hash, nil] Hash with :input and :output prices per million tokens
138
+ def get_pricing(model_id)
139
+ # Return cached pricing if available
140
+ return @pricing_cache[model_id] if @pricing_cache.key?(model_id)
141
+
142
+ # Try to fetch from RubyLLM registry
143
+ pricing = fetch_ruby_llm_pricing(model_id)
144
+
145
+ # Cache and return
146
+ @pricing_cache[model_id] = pricing
147
+ pricing
148
+ rescue StandardError => e
149
+ LanguageOperator.logger.warn('Pricing lookup failed',
150
+ model: model_id,
151
+ error: e.message)
152
+ # Cache nil to avoid repeated failures
153
+ @pricing_cache[model_id] = nil
154
+ nil
155
+ end
156
+
157
+ # Fetch pricing from RubyLLM registry
158
+ # @param model_id [String] Model identifier
159
+ # @return [Hash, nil] Pricing hash or nil
160
+ def fetch_ruby_llm_pricing(model_id)
161
+ # Check if RubyLLM is available
162
+ return nil unless defined?(RubyLLM)
163
+
164
+ # Try to find model in registry
165
+ model_info = RubyLLM.models.find(model_id)
166
+ return nil unless model_info
167
+
168
+ # Extract pricing (assuming RubyLLM provides these attributes)
169
+ if model_info.respond_to?(:input_price_per_million) &&
170
+ model_info.respond_to?(:output_price_per_million)
171
+ {
172
+ input: model_info.input_price_per_million,
173
+ output: model_info.output_price_per_million
174
+ }
175
+ else
176
+ nil
177
+ end
178
+ rescue StandardError
179
+ nil
180
+ end
181
+ end
182
+ end
183
+ end