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
@@ -82,58 +82,6 @@ module RubyLLM
82
82
  end
83
83
 
84
84
  # Compares performance between two agent versions
85
- #
86
- # @param agent_type [String] The agent class name
87
- # @param version1 [String] First version to compare (baseline)
88
- # @param version2 [String] Second version to compare
89
- # @param period [Symbol] Time scope for comparison
90
- # @return [Hash] Comparison data with stats for each version and improvement percentages
91
- def compare_versions(agent_type, version1, version2, period: :this_week)
92
- base_scope = by_agent(agent_type).public_send(period)
93
-
94
- v1_stats = stats_for_scope(base_scope.by_version(version1))
95
- v2_stats = stats_for_scope(base_scope.by_version(version2))
96
-
97
- {
98
- agent_type: agent_type,
99
- period: period,
100
- version1: { version: version1, **v1_stats },
101
- version2: { version: version2, **v2_stats },
102
- improvements: {
103
- cost_change_pct: percent_change(v1_stats[:avg_cost], v2_stats[:avg_cost]),
104
- token_change_pct: percent_change(v1_stats[:avg_tokens], v2_stats[:avg_tokens]),
105
- speed_change_pct: percent_change(v1_stats[:avg_duration_ms], v2_stats[:avg_duration_ms])
106
- }
107
- }
108
- end
109
-
110
- # Returns daily trend data for a specific agent version
111
- #
112
- # Used for sparkline charts in version comparison.
113
- #
114
- # @param agent_type [String] The agent class name
115
- # @param version [String] The version to analyze
116
- # @param days [Integer] Number of days to analyze
117
- # @return [Array<Hash>] Daily metrics sorted oldest to newest
118
- def version_trend_data(agent_type, version, days: 14)
119
- scope = by_agent(agent_type).by_version(version)
120
-
121
- (0...days).map do |days_ago|
122
- date = days_ago.days.ago.to_date
123
- day_scope = scope.where(created_at: date.beginning_of_day..date.end_of_day)
124
- count = day_scope.count
125
-
126
- {
127
- date: date,
128
- count: count,
129
- success_rate: calculate_success_rate(day_scope),
130
- avg_cost: count > 0 ? ((day_scope.total_cost_sum || 0) / count).round(6) : 0,
131
- avg_duration_ms: day_scope.avg_duration&.round || 0,
132
- avg_tokens: day_scope.avg_tokens&.round || 0
133
- }
134
- end.reverse
135
- end
136
-
137
85
  # Analyzes trends over a time period
138
86
  #
139
87
  # @param agent_type [String, nil] Filter to specific agent, or nil for all
@@ -526,9 +474,18 @@ module RubyLLM
526
474
 
527
475
  # Average time to first token for streaming executions
528
476
  #
477
+ # time_to_first_token_ms is stored in metadata JSON, so we use
478
+ # Ruby-level calculation instead of SQL aggregation.
479
+ #
529
480
  # @return [Integer, nil] Average TTFT in milliseconds, or nil if no data
530
481
  def avg_time_to_first_token
531
- streaming.where.not(time_to_first_token_ms: nil).average(:time_to_first_token_ms)&.round(0)
482
+ ttft_values = streaming
483
+ .where("metadata IS NOT NULL")
484
+ .pluck(:metadata)
485
+ .filter_map { |m| m&.dig("time_to_first_token_ms") }
486
+ return nil if ttft_values.empty?
487
+
488
+ (ttft_values.sum.to_f / ttft_values.size).round(0)
532
489
  end
533
490
 
534
491
  # Finish reason distribution
@@ -540,9 +497,11 @@ module RubyLLM
540
497
 
541
498
  # Rate limited execution count
542
499
  #
500
+ # rate_limited is stored in metadata JSON
501
+ #
543
502
  # @return [Integer] Number of executions that were rate limited
544
503
  def rate_limited_count
545
- where(rate_limited: true).count
504
+ metadata_true("rate_limited").count
546
505
  end
547
506
 
548
507
  # Rate limited rate percentage
@@ -79,17 +79,11 @@ module RubyLLM
79
79
  # @param agent_type [String] The agent class name
80
80
  # @return [ActiveRecord::Relation]
81
81
 
82
- # @!method by_version(version)
83
- # Filters to a specific agent version
84
- # @param version [String] The version string
85
- # @return [ActiveRecord::Relation]
86
-
87
82
  # @!method by_model(model_id)
88
83
  # Filters to a specific LLM model
89
84
  # @param model_id [String] The model identifier
90
85
  # @return [ActiveRecord::Relation]
91
86
  scope :by_agent, ->(agent_type) { where(agent_type: agent_type.to_s) }
92
- scope :by_version, ->(version) { where(agent_version: version.to_s) }
93
87
  scope :by_model, ->(model_id) { where(model_id: model_id.to_s) }
94
88
 
95
89
  # @!endgroup
@@ -142,15 +136,25 @@ module RubyLLM
142
136
  # @!group Parameter Scopes
143
137
 
144
138
  # @!method with_parameter(key, value = nil)
145
- # Filters by JSONB parameter key/value
139
+ # Filters by parameter key/value in the execution_details table
146
140
  # @param key [String, Symbol] Parameter key to check
147
141
  # @param value [Object, nil] Optional value to match
148
142
  # @return [ActiveRecord::Relation]
149
143
  scope :with_parameter, ->(key, value = nil) do
150
- if value
151
- where("parameters @> ?", { key => value }.to_json)
144
+ detail_table = RubyLLM::Agents::ExecutionDetail.table_name
145
+ joined = joins(:detail)
146
+ if connection.adapter_name.downcase.include?("sqlite")
147
+ if value
148
+ joined.where("json_extract(#{detail_table}.parameters, ?) = ?", "$.#{key}", value.to_s)
149
+ else
150
+ joined.where("json_extract(#{detail_table}.parameters, ?) IS NOT NULL", "$.#{key}")
151
+ end
152
152
  else
153
- where("parameters ? :key", key: key.to_s)
153
+ if value
154
+ joined.where("#{detail_table}.parameters @> ?", { key => value }.to_json)
155
+ else
156
+ joined.where("#{detail_table}.parameters ? :key", key: key.to_s)
157
+ end
154
158
  end
155
159
  end
156
160
 
@@ -196,10 +200,12 @@ module RubyLLM
196
200
  # @!method rate_limited
197
201
  # Returns executions that were rate limited
198
202
  # @return [ActiveRecord::Relation]
199
- scope :with_fallback, -> { where.not(fallback_reason: nil) }
200
- scope :retryable_errors, -> { where(retryable: true) }
201
- scope :rate_limited, -> { where(rate_limited: true) }
202
- scope :by_fallback_reason, ->(reason) { where(fallback_reason: reason) }
203
+ #
204
+ # Note: fallback_reason, retryable, and rate_limited are stored in metadata JSON
205
+ scope :with_fallback, -> { metadata_present("fallback_reason") }
206
+ scope :retryable_errors, -> { metadata_true("retryable") }
207
+ scope :rate_limited, -> { metadata_true("rate_limited") }
208
+ scope :by_fallback_reason, ->(reason) { metadata_value("fallback_reason", reason) }
203
209
 
204
210
  # @!endgroup
205
211
 
@@ -269,6 +275,47 @@ module RubyLLM
269
275
  # They can be called on scoped relations.
270
276
 
271
277
  class_methods do
278
+ # Database-agnostic JSON metadata queries
279
+ # These fields (fallback_reason, retryable, rate_limited, etc.) are stored
280
+ # in the metadata JSON column rather than as direct columns.
281
+
282
+ # Queries for metadata key presence (IS NOT NULL)
283
+ #
284
+ # @param key [String] The metadata key
285
+ # @return [ActiveRecord::Relation]
286
+ def metadata_present(key)
287
+ if connection.adapter_name.downcase.include?("sqlite")
288
+ where("json_extract(metadata, ?) IS NOT NULL", "$.#{key}")
289
+ else
290
+ where("metadata->>? IS NOT NULL", key.to_s)
291
+ end
292
+ end
293
+
294
+ # Queries for metadata boolean value being true
295
+ #
296
+ # @param key [String] The metadata key
297
+ # @return [ActiveRecord::Relation]
298
+ def metadata_true(key)
299
+ if connection.adapter_name.downcase.include?("sqlite")
300
+ where("json_extract(metadata, ?) = 1", "$.#{key}")
301
+ else
302
+ where("metadata @> ?", { key.to_s => true }.to_json)
303
+ end
304
+ end
305
+
306
+ # Queries for metadata key matching a specific value
307
+ #
308
+ # @param key [String] The metadata key
309
+ # @param value [Object] The value to match
310
+ # @return [ActiveRecord::Relation]
311
+ def metadata_value(key, value)
312
+ if connection.adapter_name.downcase.include?("sqlite")
313
+ where("json_extract(metadata, ?) = ?", "$.#{key}", value.to_s)
314
+ else
315
+ where("metadata->>? = ?", key.to_s, value.to_s)
316
+ end
317
+ end
318
+
272
319
  # Returns sum of total_cost for the current scope
273
320
  #
274
321
  # @return [Float, nil] Total cost in USD
@@ -8,8 +8,6 @@ module RubyLLM
8
8
  #
9
9
  # @!attribute [rw] agent_type
10
10
  # @return [String] Full class name of the agent (e.g., "SearchAgent")
11
- # @!attribute [rw] agent_version
12
- # @return [String] Version string for cache invalidation
13
11
  # @!attribute [rw] model_id
14
12
  # @return [String] LLM model identifier used
15
13
  # @!attribute [rw] temperature
@@ -37,7 +35,7 @@ module RubyLLM
37
35
  # @!attribute [rw] parameters
38
36
  # @return [Hash] Sanitized parameters passed to the agent
39
37
  # @!attribute [rw] metadata
40
- # @return [Hash] Custom metadata from execution_metadata hook
38
+ # @return [Hash] Custom metadata from metadata hook
41
39
  # @!attribute [rw] error_class
42
40
  # @return [String, nil] Exception class name if failed
43
41
  # @!attribute [rw] error_message
@@ -51,7 +49,6 @@ module RubyLLM
51
49
  include Execution::Metrics
52
50
  include Execution::Scopes
53
51
  include Execution::Analytics
54
- include Execution::Workflow
55
52
 
56
53
  # Status enum
57
54
  # - running: execution in progress
@@ -72,20 +69,25 @@ module RubyLLM
72
69
  has_many :child_executions, class_name: "RubyLLM::Agents::Execution",
73
70
  foreign_key: :parent_execution_id, dependent: :nullify, inverse_of: :parent_execution
74
71
 
75
- # Polymorphic association to tenant model (for llm_tenant DSL)
76
- belongs_to :tenant_record, polymorphic: true, optional: true
72
+ # Detail record for large payloads (prompts, responses, tool calls, etc.)
73
+ has_one :detail, class_name: "RubyLLM::Agents::ExecutionDetail",
74
+ foreign_key: :execution_id, dependent: :destroy
75
+
76
+ # Delegations so existing code keeps working transparently
77
+ delegate :system_prompt, :user_prompt, :response, :error_message,
78
+ :messages_summary, :tool_calls, :attempts, :fallback_chain,
79
+ :parameters, :routed_to, :classification_result,
80
+ :cached_at, :cache_creation_tokens,
81
+ to: :detail, prefix: false, allow_nil: true
77
82
 
78
83
  # Validations
79
84
  validates :agent_type, :model_id, :started_at, presence: true
80
85
  validates :status, inclusion: { in: statuses.keys }
81
- validates :agent_version, presence: true
82
86
  validates :temperature, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 2 }, allow_nil: true
83
87
  validates :input_tokens, :output_tokens, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
84
88
  validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
85
89
  validates :input_cost, :output_cost, :total_cost, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
86
90
  validates :finish_reason, inclusion: { in: FINISH_REASONS }, allow_nil: true
87
- validates :fallback_reason, inclusion: { in: FALLBACK_REASONS }, allow_nil: true
88
- validates :time_to_first_token_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
89
91
 
90
92
  before_save :calculate_total_tokens, if: -> { input_tokens_changed? || output_tokens_changed? }
91
93
  before_save :calculate_total_cost, if: -> { input_cost_changed? || output_cost_changed? }
@@ -205,7 +207,41 @@ module RubyLLM
205
207
  #
206
208
  # @return [Boolean] true if rate limiting occurred
207
209
  def rate_limited?
208
- rate_limited == true
210
+ metadata&.dig("rate_limited") == true
211
+ end
212
+
213
+ # Convenience accessors for niche fields stored in metadata JSON
214
+ %w[span_id response_cache_key fallback_reason].each do |field|
215
+ define_method(field) { metadata&.dig(field) }
216
+ define_method(:"#{field}=") { |val| self.metadata = (metadata || {}).merge(field => val) }
217
+ end
218
+
219
+ %w[time_to_first_token_ms].each do |field|
220
+ define_method(field) { metadata&.dig(field)&.to_i }
221
+ define_method(:"#{field}=") { |val| self.metadata = (metadata || {}).merge(field => val) }
222
+ end
223
+
224
+ def retryable
225
+ metadata&.dig("retryable")
226
+ end
227
+
228
+ def retryable=(val)
229
+ self.metadata = (metadata || {}).merge("retryable" => val)
230
+ end
231
+
232
+ def rate_limited
233
+ metadata&.dig("rate_limited")
234
+ end
235
+
236
+ def rate_limited=(val)
237
+ self.metadata = (metadata || {}).merge("rate_limited" => val)
238
+ end
239
+
240
+ # Convenience method to access tenant_record through the tenant
241
+ def tenant_record
242
+ return nil unless tenant_id.present?
243
+
244
+ Tenant.find_by(tenant_id: tenant_id)&.tenant_record
209
245
  end
210
246
 
211
247
  # Returns whether this execution used streaming
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Stores large payload data for an execution (prompts, responses, tool calls, etc.)
6
+ #
7
+ # Separated from {Execution} to keep the main table lean for analytics queries.
8
+ # Only created when there is detail data to store.
9
+ #
10
+ # @see Execution
11
+ # @api public
12
+ class ExecutionDetail < ::ActiveRecord::Base
13
+ self.table_name = "ruby_llm_agents_execution_details"
14
+
15
+ belongs_to :execution, class_name: "RubyLLM::Agents::Execution"
16
+ end
17
+ end
18
+ end
@@ -170,17 +170,30 @@ module RubyLLM
170
170
  effective_enforcement == :soft
171
171
  end
172
172
 
173
- # Check if within budget for a specific type
173
+ # Check if within budget for a specific type using counter columns
174
174
  #
175
175
  # @param type [Symbol] :daily_cost, :monthly_cost, :daily_tokens,
176
176
  # :monthly_tokens, :daily_executions, :monthly_executions
177
177
  # @return [Boolean]
178
178
  def within_budget?(type: :daily_cost)
179
- status = budget_status
180
- return true unless status[:enabled]
179
+ return true unless budgets_enabled?
181
180
 
182
- key = budget_status_key(type)
183
- (status.dig(key, :percentage_used) || 0) < 100
181
+ case type
182
+ when :daily_cost
183
+ within_daily_cost_budget?
184
+ when :monthly_cost
185
+ within_monthly_cost_budget?
186
+ when :daily_tokens
187
+ within_daily_token_budget?
188
+ when :monthly_tokens
189
+ within_monthly_token_budget?
190
+ when :daily_executions
191
+ within_daily_execution_budget?
192
+ when :monthly_executions
193
+ within_monthly_execution_budget?
194
+ else
195
+ true
196
+ end
184
197
  end
185
198
 
186
199
  # Get remaining budget for a specific type
@@ -188,9 +201,20 @@ module RubyLLM
188
201
  # @param type [Symbol] Budget type (see #within_budget?)
189
202
  # @return [Numeric, nil]
190
203
  def remaining_budget(type: :daily_cost)
191
- status = budget_status
192
- key = budget_status_key(type)
193
- status.dig(key, :remaining)
204
+ case type
205
+ when :daily_cost
206
+ effective_daily_limit && (ensure_daily_reset!; effective_daily_limit - daily_cost_spent)
207
+ when :monthly_cost
208
+ effective_monthly_limit && (ensure_monthly_reset!; effective_monthly_limit - monthly_cost_spent)
209
+ when :daily_tokens
210
+ effective_daily_token_limit && (ensure_daily_reset!; effective_daily_token_limit - daily_tokens_used)
211
+ when :monthly_tokens
212
+ effective_monthly_token_limit && (ensure_monthly_reset!; effective_monthly_token_limit - monthly_tokens_used)
213
+ when :daily_executions
214
+ effective_daily_execution_limit && (ensure_daily_reset!; effective_daily_execution_limit - daily_executions_count)
215
+ when :monthly_executions
216
+ effective_monthly_execution_limit && (ensure_monthly_reset!; effective_monthly_execution_limit - monthly_executions_count)
217
+ end
194
218
  end
195
219
 
196
220
  # Check budget and raise if exceeded (for hard enforcement)
@@ -198,14 +222,98 @@ module RubyLLM
198
222
  # @param agent_type [String] The agent class name
199
223
  # @raise [BudgetExceededError] If hard enforcement and over budget
200
224
  def check_budget!(agent_type = nil)
201
- BudgetTracker.check_budget!(agent_type || "Unknown", tenant_id: tenant_id)
225
+ return unless budgets_enabled?
226
+ return unless hard_enforcement?
227
+
228
+ ensure_daily_reset!
229
+ ensure_monthly_reset!
230
+
231
+ if effective_daily_limit && daily_cost_spent >= effective_daily_limit
232
+ raise Reliability::BudgetExceededError.new(
233
+ :global_daily, effective_daily_limit, daily_cost_spent, tenant_id: tenant_id
234
+ )
235
+ end
236
+
237
+ if effective_monthly_limit && monthly_cost_spent >= effective_monthly_limit
238
+ raise Reliability::BudgetExceededError.new(
239
+ :global_monthly, effective_monthly_limit, monthly_cost_spent, tenant_id: tenant_id
240
+ )
241
+ end
242
+
243
+ if effective_daily_token_limit && daily_tokens_used >= effective_daily_token_limit
244
+ raise Reliability::BudgetExceededError.new(
245
+ :global_daily_tokens, effective_daily_token_limit, daily_tokens_used, tenant_id: tenant_id
246
+ )
247
+ end
248
+
249
+ if effective_monthly_token_limit && monthly_tokens_used >= effective_monthly_token_limit
250
+ raise Reliability::BudgetExceededError.new(
251
+ :global_monthly_tokens, effective_monthly_token_limit, monthly_tokens_used, tenant_id: tenant_id
252
+ )
253
+ end
254
+
255
+ if effective_daily_execution_limit && daily_executions_count >= effective_daily_execution_limit
256
+ raise Reliability::BudgetExceededError.new(
257
+ :global_daily_executions, effective_daily_execution_limit, daily_executions_count, tenant_id: tenant_id
258
+ )
259
+ end
260
+
261
+ if effective_monthly_execution_limit && monthly_executions_count >= effective_monthly_execution_limit
262
+ raise Reliability::BudgetExceededError.new(
263
+ :global_monthly_executions, effective_monthly_execution_limit, monthly_executions_count, tenant_id: tenant_id
264
+ )
265
+ end
202
266
  end
203
267
 
204
- # Get full budget status from BudgetTracker
268
+ # Get full budget status using counter columns
205
269
  #
206
270
  # @return [Hash] Budget status with usage information
207
271
  def budget_status
208
- BudgetTracker.status(tenant_id: tenant_id)
272
+ ensure_daily_reset!
273
+ ensure_monthly_reset!
274
+
275
+ {
276
+ enabled: budgets_enabled?,
277
+ enforcement: effective_enforcement,
278
+ global_daily: budget_status_for(effective_daily_limit, daily_cost_spent),
279
+ global_monthly: budget_status_for(effective_monthly_limit, monthly_cost_spent),
280
+ global_daily_tokens: budget_status_for(effective_daily_token_limit, daily_tokens_used),
281
+ global_monthly_tokens: budget_status_for(effective_monthly_token_limit, monthly_tokens_used),
282
+ global_daily_executions: budget_status_for(effective_daily_execution_limit, daily_executions_count),
283
+ global_monthly_executions: budget_status_for(effective_monthly_execution_limit, monthly_executions_count)
284
+ }
285
+ end
286
+
287
+ # Individual budget check methods
288
+
289
+ def within_daily_cost_budget?
290
+ ensure_daily_reset!
291
+ effective_daily_limit.nil? || daily_cost_spent < effective_daily_limit
292
+ end
293
+
294
+ def within_monthly_cost_budget?
295
+ ensure_monthly_reset!
296
+ effective_monthly_limit.nil? || monthly_cost_spent < effective_monthly_limit
297
+ end
298
+
299
+ def within_daily_token_budget?
300
+ ensure_daily_reset!
301
+ effective_daily_token_limit.nil? || daily_tokens_used < effective_daily_token_limit
302
+ end
303
+
304
+ def within_monthly_token_budget?
305
+ ensure_monthly_reset!
306
+ effective_monthly_token_limit.nil? || monthly_tokens_used < effective_monthly_token_limit
307
+ end
308
+
309
+ def within_daily_execution_budget?
310
+ ensure_daily_reset!
311
+ effective_daily_execution_limit.nil? || daily_executions_count < effective_daily_execution_limit
312
+ end
313
+
314
+ def within_monthly_execution_budget?
315
+ ensure_monthly_reset!
316
+ effective_monthly_execution_limit.nil? || monthly_executions_count < effective_monthly_execution_limit
209
317
  end
210
318
 
211
319
  # Convert to config hash for BudgetTracker
@@ -256,20 +364,20 @@ module RubyLLM
256
364
  (global_config&.dig(:per_agent_monthly) || {}).merge(per_agent_monthly || {})
257
365
  end
258
366
 
259
- # Maps budget type to status key
367
+ # Builds a status hash for a single budget dimension
260
368
  #
261
- # @param type [Symbol]
262
- # @return [Symbol]
263
- def budget_status_key(type)
264
- case type
265
- when :daily_cost then :global_daily
266
- when :monthly_cost then :global_monthly
267
- when :daily_tokens then :global_daily_tokens
268
- when :monthly_tokens then :global_monthly_tokens
269
- when :daily_executions then :global_daily_executions
270
- when :monthly_executions then :global_monthly_executions
271
- else :global_daily
272
- end
369
+ # @param limit [Numeric, nil] The configured limit
370
+ # @param current [Numeric] The current usage
371
+ # @return [Hash, nil]
372
+ def budget_status_for(limit, current)
373
+ return nil unless limit
374
+
375
+ {
376
+ limit: limit,
377
+ current_spend: current,
378
+ remaining: [limit - current, 0].max,
379
+ percentage_used: (current.to_f / limit * 100).round(1)
380
+ }
273
381
  end
274
382
  end
275
383
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Tenant
6
+ # Provides atomic SQL increment of usage counters after each execution.
7
+ #
8
+ # @example Recording an execution
9
+ # tenant.record_execution!(cost: 0.05, tokens: 1200)
10
+ # tenant.record_execution!(cost: 0.01, tokens: 500, error: true)
11
+ #
12
+ # @api public
13
+ module Incrementable
14
+ extend ActiveSupport::Concern
15
+
16
+ # Records an execution by atomically incrementing all counter columns.
17
+ #
18
+ # @param cost [Numeric] The cost of the execution in USD
19
+ # @param tokens [Integer] The number of tokens used
20
+ # @param error [Boolean] Whether the execution was an error
21
+ # @return [void]
22
+ def record_execution!(cost:, tokens:, error: false)
23
+ ensure_daily_reset!
24
+ ensure_monthly_reset!
25
+
26
+ error_inc = error ? 1 : 0
27
+ status = error ? "error" : "success"
28
+
29
+ self.class.where(id: id).update_all(
30
+ self.class.sanitize_sql_array([
31
+ <<~SQL,
32
+ daily_cost_spent = daily_cost_spent + ?,
33
+ monthly_cost_spent = monthly_cost_spent + ?,
34
+ daily_tokens_used = daily_tokens_used + ?,
35
+ monthly_tokens_used = monthly_tokens_used + ?,
36
+ daily_executions_count = daily_executions_count + 1,
37
+ monthly_executions_count = monthly_executions_count + 1,
38
+ daily_error_count = daily_error_count + ?,
39
+ monthly_error_count = monthly_error_count + ?,
40
+ last_execution_at = ?,
41
+ last_execution_status = ?
42
+ SQL
43
+ cost.to_f, cost.to_f,
44
+ tokens.to_i, tokens.to_i,
45
+ error_inc, error_inc,
46
+ Time.current, status
47
+ ])
48
+ )
49
+
50
+ reload
51
+ check_soft_cap_alerts!
52
+ end
53
+
54
+ private
55
+
56
+ # Checks soft cap alerts after recording an execution.
57
+ #
58
+ # @return [void]
59
+ def check_soft_cap_alerts!
60
+ return unless soft_enforcement?
61
+
62
+ check_cost_alerts!
63
+ check_token_alerts!
64
+ check_execution_alerts!
65
+ end
66
+
67
+ # @return [void]
68
+ def check_cost_alerts!
69
+ if effective_daily_limit && daily_cost_spent >= effective_daily_limit
70
+ AlertManager.notify(:budget_soft_cap, {
71
+ tenant_id: tenant_id, type: :daily_cost,
72
+ limit: effective_daily_limit, total: daily_cost_spent
73
+ })
74
+ end
75
+ if effective_monthly_limit && monthly_cost_spent >= effective_monthly_limit
76
+ AlertManager.notify(:budget_soft_cap, {
77
+ tenant_id: tenant_id, type: :monthly_cost,
78
+ limit: effective_monthly_limit, total: monthly_cost_spent
79
+ })
80
+ end
81
+ end
82
+
83
+ # @return [void]
84
+ def check_token_alerts!
85
+ if effective_daily_token_limit && daily_tokens_used >= effective_daily_token_limit
86
+ AlertManager.notify(:token_soft_cap, {
87
+ tenant_id: tenant_id, type: :daily_tokens,
88
+ limit: effective_daily_token_limit, total: daily_tokens_used
89
+ })
90
+ end
91
+ if effective_monthly_token_limit && monthly_tokens_used >= effective_monthly_token_limit
92
+ AlertManager.notify(:token_soft_cap, {
93
+ tenant_id: tenant_id, type: :monthly_tokens,
94
+ limit: effective_monthly_token_limit, total: monthly_tokens_used
95
+ })
96
+ end
97
+ end
98
+
99
+ # @return [void]
100
+ def check_execution_alerts!
101
+ if effective_daily_execution_limit && daily_executions_count >= effective_daily_execution_limit
102
+ AlertManager.notify(:budget_soft_cap, {
103
+ tenant_id: tenant_id, type: :daily_executions,
104
+ limit: effective_daily_execution_limit, total: daily_executions_count
105
+ })
106
+ end
107
+ if effective_monthly_execution_limit && monthly_executions_count >= effective_monthly_execution_limit
108
+ AlertManager.notify(:budget_soft_cap, {
109
+ tenant_id: tenant_id, type: :monthly_executions,
110
+ limit: effective_monthly_execution_limit, total: monthly_executions_count
111
+ })
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end