ruby_llm-agents 1.3.3 → 2.0.0

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 (192) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +101 -334
  3. data/app/controllers/concerns/ruby_llm/agents/sortable.rb +0 -1
  4. data/app/controllers/ruby_llm/agents/agents_controller.rb +5 -56
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +22 -106
  6. data/app/controllers/ruby_llm/agents/executions_controller.rb +4 -114
  7. data/app/controllers/ruby_llm/agents/tenants_controller.rb +30 -2
  8. data/app/helpers/ruby_llm/agents/application_helper.rb +19 -53
  9. data/app/models/ruby_llm/agents/execution/analytics.rb +13 -54
  10. data/app/models/ruby_llm/agents/execution/scopes.rb +61 -14
  11. data/app/models/ruby_llm/agents/execution.rb +46 -10
  12. data/app/models/ruby_llm/agents/execution_detail.rb +18 -0
  13. data/app/models/ruby_llm/agents/tenant/budgetable.rb +132 -24
  14. data/app/models/ruby_llm/agents/tenant/incrementable.rb +117 -0
  15. data/app/models/ruby_llm/agents/tenant/resettable.rb +128 -0
  16. data/app/models/ruby_llm/agents/tenant/trackable.rb +46 -12
  17. data/app/models/ruby_llm/agents/tenant.rb +2 -3
  18. data/app/models/ruby_llm/agents/tenant_budget.rb +6 -3
  19. data/app/services/ruby_llm/agents/agent_registry.rb +6 -112
  20. data/app/views/layouts/ruby_llm/agents/application.html.erb +87 -252
  21. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +71 -218
  22. data/app/views/ruby_llm/agents/agents/_config_embedder.html.erb +20 -63
  23. data/app/views/ruby_llm/agents/agents/_config_image_generator.html.erb +44 -131
  24. data/app/views/ruby_llm/agents/agents/_config_moderator.html.erb +16 -57
  25. data/app/views/ruby_llm/agents/agents/_config_speaker.html.erb +39 -104
  26. data/app/views/ruby_llm/agents/agents/_config_transcriber.html.erb +29 -82
  27. data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +4 -14
  28. data/app/views/ruby_llm/agents/agents/index.html.erb +105 -274
  29. data/app/views/ruby_llm/agents/agents/show.html.erb +248 -378
  30. data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +29 -52
  31. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +73 -99
  32. data/app/views/ruby_llm/agents/dashboard/index.html.erb +228 -433
  33. data/app/views/ruby_llm/agents/executions/_execution.html.erb +1 -1
  34. data/app/views/ruby_llm/agents/executions/_filters.html.erb +4 -25
  35. data/app/views/ruby_llm/agents/executions/_list.html.erb +111 -152
  36. data/app/views/ruby_llm/agents/executions/index.html.erb +5 -7
  37. data/app/views/ruby_llm/agents/executions/show.html.erb +528 -989
  38. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +5 -21
  39. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +70 -191
  40. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +16 -44
  41. data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +12 -41
  42. data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +11 -65
  43. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +6 -5
  44. data/app/views/ruby_llm/agents/system_config/show.html.erb +240 -351
  45. data/app/views/ruby_llm/agents/tenants/_form.html.erb +67 -77
  46. data/app/views/ruby_llm/agents/tenants/edit.html.erb +7 -9
  47. data/app/views/ruby_llm/agents/tenants/index.html.erb +100 -122
  48. data/app/views/ruby_llm/agents/tenants/show.html.erb +146 -336
  49. data/config/routes.rb +0 -13
  50. data/lib/generators/ruby_llm_agents/install_generator.rb +9 -14
  51. data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +2 -12
  52. data/lib/generators/ruby_llm_agents/restructure_generator.rb +0 -2
  53. data/lib/generators/ruby_llm_agents/templates/add_usage_counters_to_tenants_migration.rb.tt +37 -0
  54. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +1 -2
  55. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +1 -1
  56. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +0 -1
  57. data/lib/generators/ruby_llm_agents/templates/create_execution_details_migration.rb.tt +27 -0
  58. data/lib/generators/ruby_llm_agents/templates/create_tenants_migration.rb.tt +25 -0
  59. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +0 -1
  60. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +9 -12
  61. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +40 -71
  62. data/lib/generators/ruby_llm_agents/templates/remove_agent_version_migration.rb.tt +13 -0
  63. data/lib/generators/ruby_llm_agents/templates/remove_workflow_columns_migration.rb.tt +19 -0
  64. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +2 -4
  65. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +0 -1
  66. data/lib/generators/ruby_llm_agents/templates/split_execution_details_migration.rb.tt +232 -0
  67. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +58 -262
  68. data/lib/ruby_llm/agents/audio/speaker.rb +0 -1
  69. data/lib/ruby_llm/agents/audio/transcriber.rb +0 -1
  70. data/lib/ruby_llm/agents/base_agent.rb +52 -6
  71. data/lib/ruby_llm/agents/core/base/callbacks.rb +142 -0
  72. data/lib/ruby_llm/agents/core/base.rb +23 -55
  73. data/lib/ruby_llm/agents/core/configuration.rb +58 -117
  74. data/lib/ruby_llm/agents/core/errors.rb +0 -58
  75. data/lib/ruby_llm/agents/core/instrumentation.rb +157 -110
  76. data/lib/ruby_llm/agents/core/llm_tenant.rb +8 -7
  77. data/lib/ruby_llm/agents/core/version.rb +1 -1
  78. data/lib/ruby_llm/agents/dsl/base.rb +157 -17
  79. data/lib/ruby_llm/agents/dsl/caching.rb +33 -2
  80. data/lib/ruby_llm/agents/dsl/reliability.rb +148 -0
  81. data/lib/ruby_llm/agents/dsl.rb +1 -2
  82. data/lib/ruby_llm/agents/image/analyzer/execution.rb +1 -2
  83. data/lib/ruby_llm/agents/image/background_remover/execution.rb +1 -2
  84. data/lib/ruby_llm/agents/image/concerns/image_operation_dsl.rb +1 -13
  85. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +2 -2
  86. data/lib/ruby_llm/agents/image/editor/dsl.rb +0 -14
  87. data/lib/ruby_llm/agents/image/editor/execution.rb +1 -10
  88. data/lib/ruby_llm/agents/image/editor.rb +0 -1
  89. data/lib/ruby_llm/agents/image/generator.rb +0 -21
  90. data/lib/ruby_llm/agents/image/pipeline/dsl.rb +0 -13
  91. data/lib/ruby_llm/agents/image/pipeline/execution.rb +0 -1
  92. data/lib/ruby_llm/agents/image/transformer/dsl.rb +0 -13
  93. data/lib/ruby_llm/agents/image/transformer/execution.rb +1 -10
  94. data/lib/ruby_llm/agents/image/transformer.rb +0 -1
  95. data/lib/ruby_llm/agents/image/upscaler/execution.rb +1 -2
  96. data/lib/ruby_llm/agents/image/variator/execution.rb +1 -2
  97. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +78 -173
  98. data/lib/ruby_llm/agents/infrastructure/attempt_tracker.rb +1 -0
  99. data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +66 -2
  100. data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +0 -12
  101. data/lib/ruby_llm/agents/infrastructure/circuit_breaker.rb +10 -13
  102. data/lib/ruby_llm/agents/infrastructure/reliability.rb +37 -2
  103. data/lib/ruby_llm/agents/pipeline/context.rb +0 -1
  104. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +28 -4
  105. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +3 -10
  106. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +88 -55
  107. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +5 -41
  108. data/lib/ruby_llm/agents/rails/engine.rb +6 -6
  109. data/lib/ruby_llm/agents/results/base.rb +1 -49
  110. data/lib/ruby_llm/agents/text/embedder.rb +0 -1
  111. data/lib/ruby_llm/agents.rb +1 -9
  112. data/lib/tasks/ruby_llm_agents.rake +34 -0
  113. metadata +12 -81
  114. data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +0 -214
  115. data/app/controllers/ruby_llm/agents/workflows_controller.rb +0 -544
  116. data/app/mailers/ruby_llm/agents/alert_mailer.rb +0 -84
  117. data/app/mailers/ruby_llm/agents/application_mailer.rb +0 -28
  118. data/app/models/ruby_llm/agents/api_configuration.rb +0 -386
  119. data/app/models/ruby_llm/agents/execution/workflow.rb +0 -170
  120. data/app/models/ruby_llm/agents/tenant/configurable.rb +0 -135
  121. data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -98
  122. data/app/views/ruby_llm/agents/agents/_version_comparison.html.erb +0 -186
  123. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +0 -126
  124. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +0 -107
  125. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +0 -18
  126. data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +0 -34
  127. data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +0 -288
  128. data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +0 -95
  129. data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +0 -97
  130. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +0 -214
  131. data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +0 -179
  132. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +0 -73
  133. data/app/views/ruby_llm/agents/dashboard/_alerts_feed.html.erb +0 -62
  134. data/app/views/ruby_llm/agents/dashboard/_breaker_strip.html.erb +0 -47
  135. data/app/views/ruby_llm/agents/dashboard/_budgets_bar.html.erb +0 -75
  136. data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +0 -56
  137. data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +0 -115
  138. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +0 -59
  139. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +0 -60
  140. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +0 -86
  141. data/app/views/ruby_llm/agents/executions/dry_run.html.erb +0 -149
  142. data/app/views/ruby_llm/agents/shared/_breadcrumbs.html.erb +0 -48
  143. data/app/views/ruby_llm/agents/shared/_nav_link.html.erb +0 -27
  144. data/app/views/ruby_llm/agents/shared/_stat_card.html.erb +0 -14
  145. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +0 -35
  146. data/app/views/ruby_llm/agents/workflows/_empty_state.html.erb +0 -22
  147. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +0 -228
  148. data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +0 -539
  149. data/app/views/ruby_llm/agents/workflows/_structure_parallel.html.erb +0 -76
  150. data/app/views/ruby_llm/agents/workflows/_structure_pipeline.html.erb +0 -74
  151. data/app/views/ruby_llm/agents/workflows/_structure_router.html.erb +0 -108
  152. data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +0 -920
  153. data/app/views/ruby_llm/agents/workflows/index.html.erb +0 -179
  154. data/app/views/ruby_llm/agents/workflows/show.html.erb +0 -467
  155. data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +0 -100
  156. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +0 -38
  157. data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +0 -48
  158. data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +0 -90
  159. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +0 -551
  160. data/lib/ruby_llm/agents/core/base/moderation_dsl.rb +0 -181
  161. data/lib/ruby_llm/agents/core/base/moderation_execution.rb +0 -274
  162. data/lib/ruby_llm/agents/core/resolved_config.rb +0 -348
  163. data/lib/ruby_llm/agents/image/generator/content_policy.rb +0 -95
  164. data/lib/ruby_llm/agents/infrastructure/redactor.rb +0 -130
  165. data/lib/ruby_llm/agents/results/moderation_result.rb +0 -158
  166. data/lib/ruby_llm/agents/text/moderator.rb +0 -237
  167. data/lib/ruby_llm/agents/workflow/approval.rb +0 -205
  168. data/lib/ruby_llm/agents/workflow/approval_store.rb +0 -179
  169. data/lib/ruby_llm/agents/workflow/async.rb +0 -220
  170. data/lib/ruby_llm/agents/workflow/async_executor.rb +0 -156
  171. data/lib/ruby_llm/agents/workflow/dsl/executor.rb +0 -467
  172. data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +0 -244
  173. data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +0 -289
  174. data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +0 -107
  175. data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +0 -150
  176. data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +0 -187
  177. data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +0 -352
  178. data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +0 -415
  179. data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +0 -257
  180. data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +0 -317
  181. data/lib/ruby_llm/agents/workflow/dsl.rb +0 -576
  182. data/lib/ruby_llm/agents/workflow/instrumentation.rb +0 -249
  183. data/lib/ruby_llm/agents/workflow/notifiers/base.rb +0 -117
  184. data/lib/ruby_llm/agents/workflow/notifiers/email.rb +0 -117
  185. data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +0 -180
  186. data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +0 -121
  187. data/lib/ruby_llm/agents/workflow/notifiers.rb +0 -70
  188. data/lib/ruby_llm/agents/workflow/orchestrator.rb +0 -416
  189. data/lib/ruby_llm/agents/workflow/result.rb +0 -592
  190. data/lib/ruby_llm/agents/workflow/thread_pool.rb +0 -185
  191. data/lib/ruby_llm/agents/workflow/throttle_manager.rb +0 -206
  192. data/lib/ruby_llm/agents/workflow/wait_result.rb +0 -213
@@ -249,7 +249,6 @@ module RubyLLM
249
249
  "ruby_llm_agents",
250
250
  "image_pipeline",
251
251
  self.class.name,
252
- self.class.version,
253
252
  Digest::SHA256.hexdigest(cache_key_input)
254
253
  ]
255
254
  components.join(":")
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../concerns/image_operation_dsl"
4
- require_relative "../generator/content_policy"
5
4
 
6
5
  module RubyLLM
7
6
  module Agents
@@ -70,18 +69,6 @@ module RubyLLM
70
69
  end
71
70
  end
72
71
 
73
- # Set or get the content policy level
74
- #
75
- # @param level [Symbol, nil] Policy level (:none, :standard, :moderate, :strict)
76
- # @return [Symbol] The content policy level
77
- def content_policy(level = nil)
78
- if level
79
- @content_policy = level
80
- else
81
- @content_policy || inherited_or_default(:content_policy, :standard)
82
- end
83
- end
84
-
85
72
  # Set a prompt template (use {prompt} as placeholder)
86
73
  #
87
74
  # @param value [String, nil] Template string
@@ -23,7 +23,6 @@ module RubyLLM
23
23
  resolve_tenant_context!
24
24
  check_budget! if budget_tracking_enabled?
25
25
  validate_inputs!
26
- validate_content_policy!
27
26
 
28
27
  # Check cache
29
28
  cached = check_cache(ImageTransformResult) if cache_enabled?
@@ -75,13 +74,6 @@ module RubyLLM
75
74
  end
76
75
  end
77
76
 
78
- def validate_content_policy!
79
- policy = self.class.content_policy
80
- return if policy == :none || policy == :standard
81
-
82
- ImageGenerator::ContentPolicy.validate!(prompt, policy)
83
- end
84
-
85
77
  def transform_images
86
78
  count = resolve_count
87
79
 
@@ -188,7 +180,6 @@ module RubyLLM
188
180
  [
189
181
  "image_transformer",
190
182
  self.class.name,
191
- self.class.version,
192
183
  resolve_model,
193
184
  resolve_size,
194
185
  resolve_strength.to_s,
@@ -209,7 +200,7 @@ module RubyLLM
209
200
  end
210
201
  end
211
202
 
212
- def build_execution_metadata(result)
203
+ def build_metadata(result)
213
204
  {
214
205
  count: result.count,
215
206
  size: result.size,
@@ -57,7 +57,6 @@ module RubyLLM
57
57
  subclass.instance_variable_set(:@version, @version)
58
58
  subclass.instance_variable_set(:@description, @description)
59
59
  subclass.instance_variable_set(:@cache_ttl, @cache_ttl)
60
- subclass.instance_variable_set(:@content_policy, @content_policy)
61
60
  subclass.instance_variable_set(:@template_string, @template_string)
62
61
  subclass.instance_variable_set(:@negative_prompt, @negative_prompt)
63
62
  subclass.instance_variable_set(:@guidance_scale, @guidance_scale)
@@ -186,7 +186,6 @@ module RubyLLM
186
186
  [
187
187
  "image_upscaler",
188
188
  self.class.name,
189
- self.class.version,
190
189
  resolve_model,
191
190
  resolve_scale.to_s,
192
191
  resolve_face_enhance.to_s,
@@ -206,7 +205,7 @@ module RubyLLM
206
205
  end
207
206
  end
208
207
 
209
- def build_execution_metadata(result)
208
+ def build_metadata(result)
210
209
  {
211
210
  scale: result.scale,
212
211
  output_size: result.output_size,
@@ -156,7 +156,6 @@ module RubyLLM
156
156
  [
157
157
  "image_variator",
158
158
  self.class.name,
159
- self.class.version,
160
159
  resolve_model,
161
160
  resolve_size,
162
161
  resolve_variation_strength.to_s,
@@ -176,7 +175,7 @@ module RubyLLM
176
175
  end
177
176
  end
178
177
 
179
- def build_execution_metadata(result)
178
+ def build_metadata(result)
180
179
  {
181
180
  count: result.count,
182
181
  size: result.size,
@@ -1,127 +1,85 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "uri"
5
- require "json"
6
-
7
3
  module RubyLLM
8
4
  module Agents
9
5
  # Alert notification dispatcher for governance events
10
6
  #
11
- # Sends notifications to configured destinations (Slack, webhooks, custom procs)
7
+ # Sends notifications via user-provided handler and ActiveSupport::Notifications
12
8
  # when important events occur like budget exceedance or circuit breaker activation.
13
9
  #
14
- # @example Sending an alert
15
- # AlertManager.notify(:budget_soft_cap, { limit: 25.0, total: 27.5 })
10
+ # @example Configure an alert handler
11
+ # RubyLLM::Agents.configure do |config|
12
+ # config.on_alert = ->(event, payload) {
13
+ # case event
14
+ # when :budget_hard_cap
15
+ # Slack::Notifier.new(ENV["SLACK_WEBHOOK"]).ping("Budget exceeded")
16
+ # end
17
+ # }
18
+ # end
19
+ #
20
+ # @example Subscribe via ActiveSupport::Notifications
21
+ # ActiveSupport::Notifications.subscribe(/^ruby_llm_agents\.alert\./) do |name, _, _, _, payload|
22
+ # event = name.sub("ruby_llm_agents.alert.", "").to_sym
23
+ # MyAlertService.handle(event, payload)
24
+ # end
16
25
  #
17
- # @see RubyLLM::Agents::Configuration
26
+ # @see RubyLLM::Agents::Configuration#on_alert
18
27
  # @api public
19
28
  module AlertManager
20
29
  class << self
21
- # Sends a notification to all configured destinations
30
+ # Sends a notification to the configured handler and emits AS::N
22
31
  #
23
32
  # @param event [Symbol] The event type (e.g., :budget_soft_cap, :breaker_open)
24
33
  # @param payload [Hash] Event-specific data
25
34
  # @return [void]
26
35
  def notify(event, payload)
27
- config = RubyLLM::Agents.configuration
28
- return unless config.alerts_enabled?
29
- return unless config.alert_events.include?(event)
30
-
31
- alerts = config.alerts
32
- full_payload = payload.merge(event: event)
33
-
34
- # Send to Slack
35
- if alerts[:slack_webhook_url].present?
36
- send_slack_alert(alerts[:slack_webhook_url], event, full_payload)
37
- end
38
-
39
- # Send to generic webhook
40
- if alerts[:webhook_url].present?
41
- send_webhook_alert(alerts[:webhook_url], full_payload)
42
- end
43
-
44
- # Call custom proc
45
- if alerts[:custom].respond_to?(:call)
46
- call_custom_alert(alerts[:custom], event, full_payload)
47
- end
36
+ full_payload = build_payload(event, payload)
48
37
 
49
- # Send email alerts
50
- if alerts[:email_recipients].present?
51
- email_events = alerts[:email_events] || config.alert_events
52
- if email_events.include?(event)
53
- send_email_alerts(event, full_payload, alerts[:email_recipients])
54
- end
55
- end
38
+ # Call user-provided handler (if set)
39
+ call_handler(event, full_payload)
56
40
 
57
- # Emit ActiveSupport::Notification for observability
41
+ # Always emit ActiveSupport::Notification
58
42
  emit_notification(event, full_payload)
43
+
44
+ # Store in cache for dashboard display
45
+ store_for_dashboard(event, full_payload)
59
46
  rescue StandardError => e
60
- # Don't let alert failures break the application
61
- Rails.logger.error("[RubyLLM::Agents::AlertManager] Failed to send alert: #{e.message}")
47
+ Rails.logger.error("[RubyLLM::Agents::AlertManager] Failed: #{e.message}")
62
48
  end
63
49
 
64
50
  private
65
51
 
66
- # Sends a Slack webhook alert
52
+ # Builds the full payload with standard fields
67
53
  #
68
- # @param webhook_url [String] The Slack webhook URL
69
54
  # @param event [Symbol] The event type
70
- # @param payload [Hash] The payload
71
- # @return [void]
72
- def send_slack_alert(webhook_url, event, payload)
73
- message = format_slack_message(event, payload)
74
-
75
- post_json(webhook_url, message)
76
- rescue StandardError => e
77
- Rails.logger.warn("[RubyLLM::Agents::AlertManager] Slack alert failed: #{e.message}")
78
- end
79
-
80
- # Sends a generic webhook alert
81
- #
82
- # @param webhook_url [String] The webhook URL
83
- # @param payload [Hash] The payload
84
- # @return [void]
85
- def send_webhook_alert(webhook_url, payload)
86
- post_json(webhook_url, payload)
87
- rescue StandardError => e
88
- Rails.logger.warn("[RubyLLM::Agents::AlertManager] Webhook alert failed: #{e.message}")
55
+ # @param payload [Hash] The original payload
56
+ # @return [Hash] Payload with event, timestamp, and tenant_id added
57
+ def build_payload(event, payload)
58
+ payload.merge(
59
+ event: event,
60
+ timestamp: Time.current,
61
+ tenant_id: RubyLLM::Agents.configuration.current_tenant_id
62
+ )
89
63
  end
90
64
 
91
- # Calls a custom alert proc
65
+ # Calls the user-provided alert handler
92
66
  #
93
- # @param custom_proc [Proc] The custom handler
94
67
  # @param event [Symbol] The event type
95
- # @param payload [Hash] The payload
68
+ # @param payload [Hash] The full payload
96
69
  # @return [void]
97
- def call_custom_alert(custom_proc, event, payload)
98
- custom_proc.call(event, payload)
99
- rescue StandardError => e
100
- Rails.logger.warn("[RubyLLM::Agents::AlertManager] Custom alert failed: #{e.message}")
101
- end
70
+ def call_handler(event, payload)
71
+ handler = RubyLLM::Agents.configuration.on_alert
72
+ return unless handler.respond_to?(:call)
102
73
 
103
- # Sends email alerts to configured recipients
104
- #
105
- # @param event [Symbol] The event type
106
- # @param payload [Hash] The payload
107
- # @param recipients [Array<String>] Email addresses
108
- # @return [void]
109
- def send_email_alerts(event, payload, recipients)
110
- Array(recipients).each do |recipient|
111
- AlertMailer.alert_notification(
112
- event: event,
113
- payload: payload,
114
- recipient: recipient
115
- ).deliver_later
116
- end
74
+ handler.call(event, payload)
117
75
  rescue StandardError => e
118
- Rails.logger.warn("[RubyLLM::Agents::AlertManager] Email alert failed: #{e.message}")
76
+ Rails.logger.warn("[RubyLLM::Agents::AlertManager] Handler failed: #{e.message}")
119
77
  end
120
78
 
121
79
  # Emits an ActiveSupport::Notification
122
80
  #
123
81
  # @param event [Symbol] The event type
124
- # @param payload [Hash] The payload
82
+ # @param payload [Hash] The full payload
125
83
  # @return [void]
126
84
  def emit_notification(event, payload)
127
85
  ActiveSupport::Notifications.instrument("ruby_llm_agents.alert.#{event}", payload)
@@ -129,102 +87,49 @@ module RubyLLM
129
87
  # Ignore notification failures
130
88
  end
131
89
 
132
- # Formats a Slack message for the event
133
- #
134
- # @param event [Symbol] The event type
135
- # @param payload [Hash] The payload
136
- # @return [Hash] Slack message payload
137
- def format_slack_message(event, payload)
138
- emoji = event_emoji(event)
139
- title = event_title(event)
140
- color = event_color(event)
141
-
142
- fields = payload.except(:event).map do |key, value|
143
- {
144
- title: key.to_s.titleize,
145
- value: value.to_s,
146
- short: true
147
- }
148
- end
149
-
150
- {
151
- attachments: [
152
- {
153
- fallback: "#{title}: #{payload.except(:event).to_json}",
154
- color: color,
155
- pretext: "#{emoji} *RubyLLM::Agents Alert*",
156
- title: title,
157
- fields: fields,
158
- footer: "RubyLLM::Agents",
159
- ts: Time.current.to_i
160
- }
161
- ]
162
- }
163
- end
164
-
165
- # Returns emoji for event type
166
- #
167
- # @param event [Symbol] The event type
168
- # @return [String] Emoji
169
- def event_emoji(event)
170
- case event
171
- when :budget_soft_cap then ":warning:"
172
- when :budget_hard_cap then ":no_entry:"
173
- when :breaker_open then ":rotating_light:"
174
- when :agent_anomaly then ":mag:"
175
- else ":bell:"
176
- end
177
- end
178
-
179
- # Returns title for event type
90
+ # Stores the alert in cache for dashboard display
180
91
  #
181
92
  # @param event [Symbol] The event type
182
- # @return [String] Human-readable title
183
- def event_title(event)
184
- case event
185
- when :budget_soft_cap then "Budget Soft Cap Reached"
186
- when :budget_hard_cap then "Budget Hard Cap Exceeded"
187
- when :breaker_open then "Circuit Breaker Opened"
188
- when :agent_anomaly then "Agent Anomaly Detected"
189
- else event.to_s.titleize
190
- end
93
+ # @param payload [Hash] The full payload
94
+ # @return [void]
95
+ def store_for_dashboard(event, payload)
96
+ cache = RubyLLM::Agents.configuration.cache_store
97
+ key = "ruby_llm_agents:alerts:recent"
98
+
99
+ alerts = cache.read(key) || []
100
+ alerts.unshift(
101
+ type: event,
102
+ message: format_message(event, payload),
103
+ agent_type: payload[:agent_type],
104
+ timestamp: payload[:timestamp]
105
+ )
106
+ alerts = alerts.first(50)
107
+
108
+ cache.write(key, alerts, expires_in: 24.hours)
109
+ rescue StandardError
110
+ # Ignore cache failures
191
111
  end
192
112
 
193
- # Returns color for event type
113
+ # Formats a human-readable message for the event
194
114
  #
195
115
  # @param event [Symbol] The event type
196
- # @return [String] Hex color code
197
- def event_color(event)
116
+ # @param payload [Hash] The full payload
117
+ # @return [String] Human-readable message
118
+ def format_message(event, payload)
198
119
  case event
199
- when :budget_soft_cap then "#FFA500" # Orange
200
- when :budget_hard_cap then "#FF0000" # Red
201
- when :breaker_open then "#FF0000" # Red
202
- when :agent_anomaly then "#FFA500" # Orange
203
- else "#0000FF" # Blue
204
- end
205
- end
206
-
207
- # Posts JSON to a URL using Net::HTTP
208
- #
209
- # @param url [String] The URL
210
- # @param payload [Hash] The payload
211
- # @return [Net::HTTPResponse]
212
- def post_json(url, payload)
213
- uri = URI.parse(url)
214
- http = Net::HTTP.new(uri.host, uri.port)
215
- http.use_ssl = (uri.scheme == "https")
216
- http.open_timeout = 5
217
- http.read_timeout = 10
218
-
219
- request = Net::HTTP::Post.new(uri.request_uri)
220
- request["Content-Type"] = "application/json"
221
- request.body = payload.to_json
222
-
223
- response = http.request(request)
224
- unless response.is_a?(Net::HTTPSuccess)
225
- Rails.logger.warn("[RubyLLM::Agents::AlertManager] Webhook returned #{response.code}: #{response.body}")
120
+ when :budget_soft_cap
121
+ "Budget soft cap reached: $#{payload[:total_cost]&.round(2)} / $#{payload[:limit]&.round(2)}"
122
+ when :budget_hard_cap
123
+ "Budget hard cap exceeded: $#{payload[:total_cost]&.round(2)} / $#{payload[:limit]&.round(2)}"
124
+ when :breaker_open
125
+ "Circuit breaker opened for #{payload[:agent_type]}"
126
+ when :breaker_closed
127
+ "Circuit breaker closed for #{payload[:agent_type]}"
128
+ when :agent_anomaly
129
+ "Anomaly detected: #{payload[:threshold_type]} threshold exceeded"
130
+ else
131
+ event.to_s.humanize
226
132
  end
227
- response
228
133
  end
229
134
  end
230
135
  end
@@ -73,6 +73,7 @@ module RubyLLM
73
73
  if error
74
74
  attempt[:error_class] = error.class.name
75
75
  attempt[:error_message] = error.message.to_s.truncate(1000)
76
+ attempt[:error_backtrace] = error.backtrace&.first(20)
76
77
  end
77
78
 
78
79
  @attempts << attempt
@@ -21,7 +21,16 @@ module RubyLLM
21
21
  # @return [Float] Current spend in USD
22
22
  def current_spend(scope, period, agent_type: nil, tenant_id: nil)
23
23
  key = SpendRecorder.budget_cache_key(scope, period, agent_type: agent_type, tenant_id: tenant_id)
24
- (BudgetQuery.cache_read(key) || 0).to_f
24
+ cached = BudgetQuery.cache_read(key)
25
+ return cached.to_f if cached.present?
26
+
27
+ # Cache miss — rebuild from executions table for global scope
28
+ if scope == :global && tenant_id.nil?
29
+ total = current_global_spend(period)
30
+ return total.to_f
31
+ end
32
+
33
+ 0.to_f
25
34
  end
26
35
 
27
36
  # Returns the current token usage for a period (global only)
@@ -31,9 +40,64 @@ module RubyLLM
31
40
  # @return [Integer] Current token usage
32
41
  def current_tokens(period, tenant_id: nil)
33
42
  key = SpendRecorder.token_cache_key(period, tenant_id: tenant_id)
34
- (BudgetQuery.cache_read(key) || 0).to_i
43
+ cached = BudgetQuery.cache_read(key)
44
+ return cached.to_i if cached.present?
45
+
46
+ # Cache miss — rebuild from executions table
47
+ if tenant_id.nil?
48
+ total = current_global_tokens(period)
49
+ return total.to_i
50
+ end
51
+
52
+ 0
53
+ end
54
+
55
+ # Rebuilds global spend from executions table on cache miss
56
+ #
57
+ # @param period [Symbol] :daily or :monthly
58
+ # @return [Float] Total spend in USD
59
+ def current_global_spend(period)
60
+ total = RubyLLM::Agents::Execution
61
+ .where("created_at >= ?", period_start(period))
62
+ .where(tenant_id: nil)
63
+ .sum(:total_cost)
64
+ key = SpendRecorder.budget_cache_key(:global, period)
65
+ BudgetQuery.cache_write(key, total, expires_in: period_ttl(period))
66
+ total
67
+ end
68
+
69
+ # Rebuilds global token usage from executions table on cache miss
70
+ #
71
+ # @param period [Symbol] :daily or :monthly
72
+ # @return [Integer] Total tokens used
73
+ def current_global_tokens(period)
74
+ total = RubyLLM::Agents::Execution
75
+ .where("created_at >= ?", period_start(period))
76
+ .where(tenant_id: nil)
77
+ .sum(:total_tokens)
78
+ key = SpendRecorder.token_cache_key(period)
79
+ BudgetQuery.cache_write(key, total, expires_in: period_ttl(period))
80
+ total
35
81
  end
36
82
 
83
+ private
84
+
85
+ def period_start(period)
86
+ case period
87
+ when :daily then Date.current.beginning_of_day
88
+ when :monthly then Date.current.beginning_of_month.beginning_of_day
89
+ end
90
+ end
91
+
92
+ def period_ttl(period)
93
+ case period
94
+ when :daily then 1.day
95
+ when :monthly then 31.days
96
+ end
97
+ end
98
+
99
+ public
100
+
37
101
  # Returns the remaining budget for a scope and period
38
102
  #
39
103
  # @param scope [Symbol] :global or :agent
@@ -154,10 +154,6 @@ module RubyLLM
154
154
  # @param budget_config [Hash] Budget configuration
155
155
  # @return [void]
156
156
  def check_soft_cap_alerts(agent_type, tenant_id, budget_config)
157
- config = RubyLLM::Agents.configuration
158
- return unless config.alerts_enabled?
159
- return unless config.alert_events.include?(:budget_soft_cap) || config.alert_events.include?(:budget_hard_cap)
160
-
161
157
  # Check global daily
162
158
  check_budget_alert(:global_daily, budget_config[:global_daily],
163
159
  BudgetQuery.current_spend(:global, :daily, tenant_id: tenant_id),
@@ -199,8 +195,6 @@ module RubyLLM
199
195
  return if current <= limit
200
196
 
201
197
  event = budget_config[:enforcement] == :hard ? :budget_hard_cap : :budget_soft_cap
202
- config = RubyLLM::Agents.configuration
203
- return unless config.alert_events.include?(event)
204
198
 
205
199
  # Prevent duplicate alerts by using a cache key (include tenant for isolation)
206
200
  key = alert_cache_key("budget_alert", scope, tenant_id)
@@ -225,10 +219,6 @@ module RubyLLM
225
219
  # @param budget_config [Hash] Budget configuration
226
220
  # @return [void]
227
221
  def check_soft_token_alerts(agent_type, tenant_id, budget_config)
228
- config = RubyLLM::Agents.configuration
229
- return unless config.alerts_enabled?
230
- return unless config.alert_events.include?(:token_soft_cap) || config.alert_events.include?(:token_hard_cap)
231
-
232
222
  # Check global daily tokens
233
223
  check_token_alert(:global_daily_tokens, budget_config[:global_daily_tokens],
234
224
  BudgetQuery.current_tokens(:daily, tenant_id: tenant_id),
@@ -254,8 +244,6 @@ module RubyLLM
254
244
  return if current <= limit
255
245
 
256
246
  event = budget_config[:enforcement] == :hard ? :token_hard_cap : :token_soft_cap
257
- config = RubyLLM::Agents.configuration
258
- return unless config.alert_events.include?(event)
259
247
 
260
248
  # Prevent duplicate alerts
261
249
  key = alert_cache_key("token_alert", scope, tenant_id)
@@ -174,19 +174,16 @@ module RubyLLM
174
174
  def open_breaker!
175
175
  cache_write(open_key, Time.current.to_s, expires_in: cooldown_seconds)
176
176
 
177
- # Fire alert if configured
178
- if RubyLLM::Agents.configuration.alerts_enabled? &&
179
- RubyLLM::Agents.configuration.alert_events.include?(:breaker_open)
180
- AlertManager.notify(:breaker_open, {
181
- agent_type: agent_type,
182
- model_id: model_id,
183
- tenant_id: tenant_id,
184
- errors: errors_threshold,
185
- within: window_seconds,
186
- cooldown: cooldown_seconds,
187
- timestamp: Time.current.iso8601
188
- })
189
- end
177
+ # Fire alert
178
+ AlertManager.notify(:breaker_open, {
179
+ agent_type: agent_type,
180
+ model_id: model_id,
181
+ tenant_id: tenant_id,
182
+ errors: errors_threshold,
183
+ within: window_seconds,
184
+ cooldown: cooldown_seconds,
185
+ timestamp: Time.current.iso8601
186
+ })
190
187
  end
191
188
 
192
189
  # Returns the cache key for the failure counter
@@ -88,7 +88,7 @@ module RubyLLM
88
88
  #
89
89
  # @api public
90
90
  class AllModelsExhaustedError < Error
91
- attr_reader :models_tried, :last_error, :attempts
91
+ attr_reader :models_tried, :last_error, :attempts, :errors
92
92
 
93
93
  # @param models_tried [Array<String>] List of models that were attempted
94
94
  # @param last_error [Exception] The last error that occurred
@@ -97,7 +97,42 @@ module RubyLLM
97
97
  @models_tried = models_tried
98
98
  @last_error = last_error
99
99
  @attempts = attempts
100
- super("All models exhausted: #{models_tried.join(', ')}. Last error: #{last_error.message}")
100
+ @errors = build_errors
101
+ super(build_message)
102
+ end
103
+
104
+ private
105
+
106
+ def build_errors
107
+ return [] unless @attempts&.any?
108
+
109
+ @attempts.filter_map do |attempt|
110
+ if attempt["short_circuited"]
111
+ { model: attempt["model_id"], error_class: "CircuitBreakerOpen", error_message: "circuit breaker open",
112
+ error_backtrace: nil }
113
+ elsif attempt["error_class"]
114
+ { model: attempt["model_id"], error_class: attempt["error_class"], error_message: attempt["error_message"],
115
+ error_backtrace: attempt["error_backtrace"] }
116
+ end
117
+ end
118
+ end
119
+
120
+ def build_message
121
+ parts = ["All models exhausted:"]
122
+ if @attempts&.any?
123
+ @attempts.each do |attempt|
124
+ model = attempt["model_id"]
125
+ if attempt["short_circuited"]
126
+ parts << " #{model}: circuit breaker open"
127
+ elsif attempt["error_class"]
128
+ parts << " #{model}: #{attempt['error_class']} - #{attempt['error_message']}"
129
+ end
130
+ end
131
+ else
132
+ parts << " Models: #{@models_tried.join(', ')}"
133
+ parts << " Last error: #{@last_error.message}"
134
+ end
135
+ parts.join("\n")
101
136
  end
102
137
  end
103
138
 
@@ -234,7 +234,6 @@ module RubyLLM
234
234
  when /image/, /generator/, /analyzer/, /editor/, /transform/, /upscale/, /variat/, /background/
235
235
  :image
236
236
  when /transcrib/, /speak/ then :audio
237
- when /moderat/ then :moderation
238
237
  else :conversation
239
238
  end
240
239
  end