ruby_llm-agents 0.2.4 → 0.3.1

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +413 -0
  3. data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
  4. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
  5. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
  6. data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
  7. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
  8. data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
  9. data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
  10. data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
  12. data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
  13. data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
  14. data/app/models/ruby_llm/agents/execution.rb +259 -16
  15. data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
  16. data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
  17. data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
  18. data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
  19. data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
  20. data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
  21. data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
  22. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
  23. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
  24. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
  25. data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
  26. data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
  27. data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
  28. data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
  29. data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
  30. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
  31. data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
  32. data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
  33. data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
  34. data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
  35. data/config/routes.rb +7 -0
  36. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
  37. data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
  39. data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
  41. data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
  42. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
  43. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
  44. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +143 -8
  45. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
  46. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
  47. data/lib/ruby_llm/agents/alert_manager.rb +207 -0
  48. data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
  49. data/lib/ruby_llm/agents/base.rb +597 -112
  50. data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
  51. data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
  52. data/lib/ruby_llm/agents/configuration.rb +279 -1
  53. data/lib/ruby_llm/agents/engine.rb +58 -6
  54. data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
  55. data/lib/ruby_llm/agents/inflections.rb +13 -2
  56. data/lib/ruby_llm/agents/instrumentation.rb +538 -87
  57. data/lib/ruby_llm/agents/redactor.rb +130 -0
  58. data/lib/ruby_llm/agents/reliability.rb +185 -0
  59. data/lib/ruby_llm/agents/version.rb +3 -1
  60. data/lib/ruby_llm/agents.rb +52 -0
  61. metadata +41 -2
  62. data/app/controllers/ruby_llm/agents/application_controller.rb +0 -37
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module RubyLLM
7
+ module Agents
8
+ # Alert notification dispatcher for governance events
9
+ #
10
+ # Sends notifications to configured destinations (Slack, webhooks, custom procs)
11
+ # when important events occur like budget exceedance or circuit breaker activation.
12
+ #
13
+ # @example Sending an alert
14
+ # AlertManager.notify(:budget_soft_cap, { limit: 25.0, total: 27.5 })
15
+ #
16
+ # @see RubyLLM::Agents::Configuration
17
+ # @api public
18
+ module AlertManager
19
+ class << self
20
+ # Sends a notification to all configured destinations
21
+ #
22
+ # @param event [Symbol] The event type (e.g., :budget_soft_cap, :breaker_open)
23
+ # @param payload [Hash] Event-specific data
24
+ # @return [void]
25
+ def notify(event, payload)
26
+ config = RubyLLM::Agents.configuration
27
+ return unless config.alerts_enabled?
28
+ return unless config.alert_events.include?(event)
29
+
30
+ alerts = config.alerts
31
+ full_payload = payload.merge(event: event)
32
+
33
+ # Send to Slack
34
+ if alerts[:slack_webhook_url].present?
35
+ send_slack_alert(alerts[:slack_webhook_url], event, full_payload)
36
+ end
37
+
38
+ # Send to generic webhook
39
+ if alerts[:webhook_url].present?
40
+ send_webhook_alert(alerts[:webhook_url], full_payload)
41
+ end
42
+
43
+ # Call custom proc
44
+ if alerts[:custom].respond_to?(:call)
45
+ call_custom_alert(alerts[:custom], event, full_payload)
46
+ end
47
+
48
+ # Emit ActiveSupport::Notification for observability
49
+ emit_notification(event, full_payload)
50
+ rescue StandardError => e
51
+ # Don't let alert failures break the application
52
+ Rails.logger.error("[RubyLLM::Agents::AlertManager] Failed to send alert: #{e.message}")
53
+ end
54
+
55
+ private
56
+
57
+ # Sends a Slack webhook alert
58
+ #
59
+ # @param webhook_url [String] The Slack webhook URL
60
+ # @param event [Symbol] The event type
61
+ # @param payload [Hash] The payload
62
+ # @return [void]
63
+ def send_slack_alert(webhook_url, event, payload)
64
+ message = format_slack_message(event, payload)
65
+
66
+ post_json(webhook_url, message)
67
+ rescue StandardError => e
68
+ Rails.logger.warn("[RubyLLM::Agents::AlertManager] Slack alert failed: #{e.message}")
69
+ end
70
+
71
+ # Sends a generic webhook alert
72
+ #
73
+ # @param webhook_url [String] The webhook URL
74
+ # @param payload [Hash] The payload
75
+ # @return [void]
76
+ def send_webhook_alert(webhook_url, payload)
77
+ post_json(webhook_url, payload)
78
+ rescue StandardError => e
79
+ Rails.logger.warn("[RubyLLM::Agents::AlertManager] Webhook alert failed: #{e.message}")
80
+ end
81
+
82
+ # Calls a custom alert proc
83
+ #
84
+ # @param custom_proc [Proc] The custom handler
85
+ # @param event [Symbol] The event type
86
+ # @param payload [Hash] The payload
87
+ # @return [void]
88
+ def call_custom_alert(custom_proc, event, payload)
89
+ custom_proc.call(event, payload)
90
+ rescue StandardError => e
91
+ Rails.logger.warn("[RubyLLM::Agents::AlertManager] Custom alert failed: #{e.message}")
92
+ end
93
+
94
+ # Emits an ActiveSupport::Notification
95
+ #
96
+ # @param event [Symbol] The event type
97
+ # @param payload [Hash] The payload
98
+ # @return [void]
99
+ def emit_notification(event, payload)
100
+ ActiveSupport::Notifications.instrument("ruby_llm_agents.alert.#{event}", payload)
101
+ rescue StandardError
102
+ # Ignore notification failures
103
+ end
104
+
105
+ # Formats a Slack message for the event
106
+ #
107
+ # @param event [Symbol] The event type
108
+ # @param payload [Hash] The payload
109
+ # @return [Hash] Slack message payload
110
+ def format_slack_message(event, payload)
111
+ emoji = event_emoji(event)
112
+ title = event_title(event)
113
+ color = event_color(event)
114
+
115
+ fields = payload.except(:event).map do |key, value|
116
+ {
117
+ title: key.to_s.titleize,
118
+ value: value.to_s,
119
+ short: true
120
+ }
121
+ end
122
+
123
+ {
124
+ attachments: [
125
+ {
126
+ fallback: "#{title}: #{payload.except(:event).to_json}",
127
+ color: color,
128
+ pretext: "#{emoji} *RubyLLM::Agents Alert*",
129
+ title: title,
130
+ fields: fields,
131
+ footer: "RubyLLM::Agents",
132
+ ts: Time.current.to_i
133
+ }
134
+ ]
135
+ }
136
+ end
137
+
138
+ # Returns emoji for event type
139
+ #
140
+ # @param event [Symbol] The event type
141
+ # @return [String] Emoji
142
+ def event_emoji(event)
143
+ case event
144
+ when :budget_soft_cap then ":warning:"
145
+ when :budget_hard_cap then ":no_entry:"
146
+ when :breaker_open then ":rotating_light:"
147
+ when :agent_anomaly then ":mag:"
148
+ else ":bell:"
149
+ end
150
+ end
151
+
152
+ # Returns title for event type
153
+ #
154
+ # @param event [Symbol] The event type
155
+ # @return [String] Human-readable title
156
+ def event_title(event)
157
+ case event
158
+ when :budget_soft_cap then "Budget Soft Cap Reached"
159
+ when :budget_hard_cap then "Budget Hard Cap Exceeded"
160
+ when :breaker_open then "Circuit Breaker Opened"
161
+ when :agent_anomaly then "Agent Anomaly Detected"
162
+ else event.to_s.titleize
163
+ end
164
+ end
165
+
166
+ # Returns color for event type
167
+ #
168
+ # @param event [Symbol] The event type
169
+ # @return [String] Hex color code
170
+ def event_color(event)
171
+ case event
172
+ when :budget_soft_cap then "#FFA500" # Orange
173
+ when :budget_hard_cap then "#FF0000" # Red
174
+ when :breaker_open then "#FF0000" # Red
175
+ when :agent_anomaly then "#FFA500" # Orange
176
+ else "#0000FF" # Blue
177
+ end
178
+ end
179
+
180
+ # Posts JSON to a URL
181
+ #
182
+ # @param url [String] The URL
183
+ # @param payload [Hash] The payload
184
+ # @return [Net::HTTPResponse]
185
+ def post_json(url, payload)
186
+ uri = URI.parse(url)
187
+ http = Net::HTTP.new(uri.host, uri.port)
188
+ http.use_ssl = uri.scheme == "https"
189
+ http.open_timeout = 5
190
+ http.read_timeout = 10
191
+
192
+ request = Net::HTTP::Post.new(uri.request_uri)
193
+ request["Content-Type"] = "application/json"
194
+ request.body = payload.to_json
195
+
196
+ response = http.request(request)
197
+
198
+ unless response.is_a?(Net::HTTPSuccess)
199
+ Rails.logger.warn("[RubyLLM::Agents::AlertManager] Webhook returned #{response.code}: #{response.body}")
200
+ end
201
+
202
+ response
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Tracks attempts during agent execution with reliability features
6
+ #
7
+ # Records details about each attempt (retries and fallbacks) during an execution,
8
+ # including timing, token usage, and errors. This data is stored in the execution
9
+ # record's `attempts` JSONB array.
10
+ #
11
+ # @example Tracking attempts
12
+ # tracker = AttemptTracker.new
13
+ # attempt = tracker.start_attempt("gpt-4o")
14
+ # # ... execute LLM call ...
15
+ # tracker.complete_attempt(attempt, success: true, response: response)
16
+ #
17
+ # @see RubyLLM::Agents::Instrumentation
18
+ # @api private
19
+ class AttemptTracker
20
+ attr_reader :attempts
21
+
22
+ def initialize
23
+ @attempts = []
24
+ @current_attempt = nil
25
+ end
26
+
27
+ # Starts tracking a new attempt
28
+ #
29
+ # @param model_id [String] The model identifier being used
30
+ # @return [Hash] The attempt hash (pass to complete_attempt)
31
+ def start_attempt(model_id)
32
+ @current_attempt = {
33
+ model_id: model_id,
34
+ started_at: Time.current.iso8601,
35
+ completed_at: nil,
36
+ duration_ms: nil,
37
+ input_tokens: nil,
38
+ output_tokens: nil,
39
+ cached_tokens: 0,
40
+ cache_creation_tokens: 0,
41
+ error_class: nil,
42
+ error_message: nil,
43
+ short_circuited: false
44
+ }
45
+
46
+ emit_start_notification(@current_attempt)
47
+
48
+ @current_attempt
49
+ end
50
+
51
+ # Completes the current attempt with results
52
+ #
53
+ # @param attempt [Hash] The attempt hash from start_attempt
54
+ # @param success [Boolean] Whether the attempt succeeded
55
+ # @param response [Object, nil] The LLM response (if successful)
56
+ # @param error [Exception, nil] The error (if failed)
57
+ # @return [Hash] The completed attempt
58
+ def complete_attempt(attempt, success:, response: nil, error: nil)
59
+ started_at = Time.parse(attempt[:started_at])
60
+ completed_at = Time.current
61
+
62
+ attempt[:completed_at] = completed_at.iso8601
63
+ attempt[:duration_ms] = ((completed_at - started_at) * 1000).round
64
+
65
+ if response
66
+ attempt[:input_tokens] = safe_value(response, :input_tokens)
67
+ attempt[:output_tokens] = safe_value(response, :output_tokens)
68
+ attempt[:cached_tokens] = safe_value(response, :cached_tokens, 0)
69
+ attempt[:cache_creation_tokens] = safe_value(response, :cache_creation_tokens, 0)
70
+ attempt[:model_id] = safe_value(response, :model_id) || attempt[:model_id]
71
+ end
72
+
73
+ if error
74
+ attempt[:error_class] = error.class.name
75
+ attempt[:error_message] = error.message.to_s.truncate(1000)
76
+ end
77
+
78
+ @attempts << attempt
79
+ @current_attempt = nil
80
+
81
+ emit_finish_notification(attempt, success)
82
+
83
+ attempt
84
+ end
85
+
86
+ # Records a short-circuited attempt (circuit breaker open)
87
+ #
88
+ # @param model_id [String] The model identifier
89
+ # @return [Hash] The recorded attempt
90
+ def record_short_circuit(model_id)
91
+ now = Time.current
92
+
93
+ attempt = {
94
+ model_id: model_id,
95
+ started_at: now.iso8601,
96
+ completed_at: now.iso8601,
97
+ duration_ms: 0,
98
+ input_tokens: nil,
99
+ output_tokens: nil,
100
+ cached_tokens: 0,
101
+ cache_creation_tokens: 0,
102
+ error_class: "RubyLLM::Agents::Reliability::CircuitBreakerOpenError",
103
+ error_message: "Circuit breaker is open",
104
+ short_circuited: true
105
+ }
106
+
107
+ @attempts << attempt
108
+
109
+ emit_short_circuit_notification(attempt)
110
+
111
+ attempt
112
+ end
113
+
114
+ # Finds the successful attempt (if any)
115
+ #
116
+ # @return [Hash, nil] The successful attempt or nil
117
+ def successful_attempt
118
+ @attempts.find { |a| a[:error_class].nil? && !a[:short_circuited] }
119
+ end
120
+
121
+ # Finds the last failed attempt
122
+ #
123
+ # @return [Hash, nil] The last failed attempt or nil
124
+ def last_failed_attempt
125
+ @attempts.reverse.find { |a| a[:error_class].present? }
126
+ end
127
+
128
+ # Returns all failed attempts
129
+ #
130
+ # @return [Array<Hash>] Failed attempt data
131
+ def failed_attempts
132
+ @attempts.select { |a| a[:error_class].present? }
133
+ end
134
+
135
+ # Checks if a fallback model was used
136
+ #
137
+ # A fallback was used if multiple models were tried (more than one unique model)
138
+ # or if the successful model is different from the first attempted model.
139
+ #
140
+ # @return [Boolean] true if a fallback model was used
141
+ def used_fallback?
142
+ return false if @attempts.empty?
143
+
144
+ first_model = @attempts.first[:model_id]
145
+ successful = successful_attempt
146
+
147
+ # Fallback used if successful model differs from first attempted
148
+ return true if successful && successful[:model_id] != first_model
149
+
150
+ # Or if we tried multiple models (even if all failed)
151
+ @attempts.map { |a| a[:model_id] }.uniq.length > 1
152
+ end
153
+
154
+ # Returns the chosen model (from successful attempt)
155
+ #
156
+ # @return [String, nil] The model ID that succeeded
157
+ def chosen_model_id
158
+ successful_attempt&.dig(:model_id)
159
+ end
160
+
161
+ # Calculates total tokens across all attempts
162
+ #
163
+ # @return [Integer] Total input + output tokens
164
+ def total_tokens
165
+ @attempts.sum { |a| (a[:input_tokens] || 0) + (a[:output_tokens] || 0) }
166
+ end
167
+
168
+ # Calculates total input tokens across all attempts
169
+ #
170
+ # @return [Integer] Total input tokens
171
+ def total_input_tokens
172
+ @attempts.sum { |a| a[:input_tokens] || 0 }
173
+ end
174
+
175
+ # Calculates total output tokens across all attempts
176
+ #
177
+ # @return [Integer] Total output tokens
178
+ def total_output_tokens
179
+ @attempts.sum { |a| a[:output_tokens] || 0 }
180
+ end
181
+
182
+ # Calculates total cached tokens across all attempts
183
+ #
184
+ # @return [Integer] Total cached tokens
185
+ def total_cached_tokens
186
+ @attempts.sum { |a| a[:cached_tokens] || 0 }
187
+ end
188
+
189
+ # Calculates total duration across all attempts
190
+ #
191
+ # @return [Integer] Total duration in milliseconds
192
+ def total_duration_ms
193
+ @attempts.sum { |a| a[:duration_ms] || 0 }
194
+ end
195
+
196
+ # Returns the number of attempts made
197
+ #
198
+ # @return [Integer] Number of attempts
199
+ def attempts_count
200
+ @attempts.length
201
+ end
202
+
203
+ # Returns the number of failed attempts
204
+ #
205
+ # @return [Integer] Number of failed attempts
206
+ def failed_attempts_count
207
+ @attempts.count { |a| a[:error_class].present? }
208
+ end
209
+
210
+ # Returns the number of short-circuited attempts
211
+ #
212
+ # @return [Integer] Number of short-circuited attempts
213
+ def short_circuited_count
214
+ @attempts.count { |a| a[:short_circuited] }
215
+ end
216
+
217
+ # Returns attempts as JSON-compatible array
218
+ #
219
+ # @return [Array<Hash>] Attempts data for persistence
220
+ def to_json_array
221
+ @attempts.map do |attempt|
222
+ attempt.transform_keys(&:to_s)
223
+ end
224
+ end
225
+
226
+ private
227
+
228
+ # Safely extracts a value from a response object
229
+ #
230
+ # @param response [Object] The response object
231
+ # @param method [Symbol] The method to call
232
+ # @param default [Object] Default value if method unavailable
233
+ # @return [Object] The extracted value or default
234
+ def safe_value(response, method, default = nil)
235
+ return default unless response.respond_to?(method)
236
+ response.public_send(method)
237
+ rescue StandardError
238
+ default
239
+ end
240
+
241
+ # Emits notification when attempt starts
242
+ #
243
+ # @param attempt [Hash] The attempt data
244
+ # @return [void]
245
+ def emit_start_notification(attempt)
246
+ ActiveSupport::Notifications.instrument(
247
+ "ruby_llm_agents.attempt.start",
248
+ model_id: attempt[:model_id],
249
+ attempt_index: @attempts.length
250
+ )
251
+ rescue StandardError
252
+ # Ignore notification failures
253
+ end
254
+
255
+ # Emits notification when attempt finishes
256
+ #
257
+ # @param attempt [Hash] The attempt data
258
+ # @param success [Boolean] Whether it succeeded
259
+ # @return [void]
260
+ def emit_finish_notification(attempt, success)
261
+ event = success ? "ruby_llm_agents.attempt.finish" : "ruby_llm_agents.attempt.error"
262
+
263
+ payload = {
264
+ model_id: attempt[:model_id],
265
+ duration_ms: attempt[:duration_ms],
266
+ input_tokens: attempt[:input_tokens],
267
+ output_tokens: attempt[:output_tokens],
268
+ success: success
269
+ }
270
+
271
+ if !success && attempt[:error_class]
272
+ payload[:error_class] = attempt[:error_class]
273
+ payload[:error_message] = attempt[:error_message]
274
+ end
275
+
276
+ ActiveSupport::Notifications.instrument(event, payload)
277
+ rescue StandardError
278
+ # Ignore notification failures
279
+ end
280
+
281
+ # Emits notification when attempt is short-circuited
282
+ #
283
+ # @param attempt [Hash] The attempt data
284
+ # @return [void]
285
+ def emit_short_circuit_notification(attempt)
286
+ ActiveSupport::Notifications.instrument(
287
+ "ruby_llm_agents.attempt.short_circuit",
288
+ model_id: attempt[:model_id]
289
+ )
290
+ rescue StandardError
291
+ # Ignore notification failures
292
+ end
293
+ end
294
+ end
295
+ end