ruby_llm-agents 0.5.0 → 1.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +189 -31
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +136 -16
  4. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +29 -9
  5. data/app/controllers/ruby_llm/agents/workflows_controller.rb +355 -0
  6. data/app/helpers/ruby_llm/agents/application_helper.rb +25 -0
  7. data/app/models/ruby_llm/agents/execution.rb +3 -0
  8. data/app/models/ruby_llm/agents/tenant_budget.rb +58 -15
  9. data/app/services/ruby_llm/agents/agent_registry.rb +51 -12
  10. data/app/views/layouts/ruby_llm/agents/application.html.erb +2 -29
  11. data/app/views/ruby_llm/agents/agents/_agent.html.erb +13 -1
  12. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +235 -0
  13. data/app/views/ruby_llm/agents/agents/_config_embedder.html.erb +70 -0
  14. data/app/views/ruby_llm/agents/agents/_config_image_generator.html.erb +152 -0
  15. data/app/views/ruby_llm/agents/agents/_config_moderator.html.erb +63 -0
  16. data/app/views/ruby_llm/agents/agents/_config_speaker.html.erb +108 -0
  17. data/app/views/ruby_llm/agents/agents/_config_transcriber.html.erb +91 -0
  18. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +1 -1
  19. data/app/views/ruby_llm/agents/agents/index.html.erb +74 -9
  20. data/app/views/ruby_llm/agents/agents/show.html.erb +18 -378
  21. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +269 -15
  22. data/app/views/ruby_llm/agents/executions/show.html.erb +16 -0
  23. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +93 -0
  24. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +236 -0
  25. data/app/views/ruby_llm/agents/workflows/_structure_parallel.html.erb +76 -0
  26. data/app/views/ruby_llm/agents/workflows/_structure_pipeline.html.erb +74 -0
  27. data/app/views/ruby_llm/agents/workflows/_structure_router.html.erb +108 -0
  28. data/app/views/ruby_llm/agents/workflows/show.html.erb +442 -0
  29. data/config/routes.rb +1 -0
  30. data/lib/generators/ruby_llm_agents/agent_generator.rb +56 -7
  31. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +110 -0
  32. data/lib/generators/ruby_llm_agents/embedder_generator.rb +107 -0
  33. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +115 -0
  34. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +108 -0
  35. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +116 -0
  36. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +178 -0
  37. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +109 -0
  38. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +103 -0
  39. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +102 -0
  40. data/lib/generators/ruby_llm_agents/install_generator.rb +76 -4
  41. data/lib/generators/ruby_llm_agents/restructure_generator.rb +292 -0
  42. data/lib/generators/ruby_llm_agents/speaker_generator.rb +121 -0
  43. data/lib/generators/ruby_llm_agents/templates/add_execution_type_migration.rb.tt +8 -0
  44. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +99 -84
  45. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +42 -40
  46. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +26 -0
  47. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +50 -0
  48. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +26 -0
  49. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +20 -0
  50. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +38 -0
  51. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +139 -0
  52. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +21 -0
  53. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +20 -0
  54. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +20 -0
  55. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +49 -0
  56. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +53 -0
  57. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +44 -0
  58. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +41 -0
  59. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +45 -0
  60. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +35 -0
  61. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +47 -0
  62. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +50 -0
  63. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +44 -0
  64. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +38 -0
  65. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +33 -0
  66. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +228 -0
  67. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +131 -0
  68. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +255 -0
  69. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +120 -0
  70. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +102 -0
  71. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +282 -0
  72. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +228 -0
  73. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +120 -0
  74. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +110 -0
  75. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +120 -0
  76. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +212 -0
  77. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +227 -0
  78. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +251 -0
  79. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +300 -0
  80. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +56 -0
  81. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +51 -0
  82. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +107 -0
  83. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +152 -1
  84. data/lib/ruby_llm/agents/audio/speaker.rb +553 -0
  85. data/lib/ruby_llm/agents/audio/transcriber.rb +669 -0
  86. data/lib/ruby_llm/agents/base_agent.rb +675 -0
  87. data/lib/ruby_llm/agents/core/base/moderation_dsl.rb +181 -0
  88. data/lib/ruby_llm/agents/core/base/moderation_execution.rb +274 -0
  89. data/lib/ruby_llm/agents/core/base.rb +135 -0
  90. data/lib/ruby_llm/agents/core/configuration.rb +981 -0
  91. data/lib/ruby_llm/agents/core/errors.rb +150 -0
  92. data/lib/ruby_llm/agents/{instrumentation.rb → core/instrumentation.rb} +22 -1
  93. data/lib/ruby_llm/agents/core/llm_tenant.rb +358 -0
  94. data/lib/ruby_llm/agents/{version.rb → core/version.rb} +1 -1
  95. data/lib/ruby_llm/agents/dsl/base.rb +110 -0
  96. data/lib/ruby_llm/agents/dsl/caching.rb +142 -0
  97. data/lib/ruby_llm/agents/dsl/reliability.rb +307 -0
  98. data/lib/ruby_llm/agents/dsl.rb +41 -0
  99. data/lib/ruby_llm/agents/image/analyzer/dsl.rb +130 -0
  100. data/lib/ruby_llm/agents/image/analyzer/execution.rb +402 -0
  101. data/lib/ruby_llm/agents/image/analyzer.rb +90 -0
  102. data/lib/ruby_llm/agents/image/background_remover/dsl.rb +154 -0
  103. data/lib/ruby_llm/agents/image/background_remover/execution.rb +240 -0
  104. data/lib/ruby_llm/agents/image/background_remover.rb +89 -0
  105. data/lib/ruby_llm/agents/image/concerns/image_operation_dsl.rb +91 -0
  106. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +165 -0
  107. data/lib/ruby_llm/agents/image/editor/dsl.rb +56 -0
  108. data/lib/ruby_llm/agents/image/editor/execution.rb +207 -0
  109. data/lib/ruby_llm/agents/image/editor.rb +92 -0
  110. data/lib/ruby_llm/agents/image/generator/active_storage_support.rb +127 -0
  111. data/lib/ruby_llm/agents/image/generator/content_policy.rb +95 -0
  112. data/lib/ruby_llm/agents/image/generator/pricing.rb +353 -0
  113. data/lib/ruby_llm/agents/image/generator/templates.rb +124 -0
  114. data/lib/ruby_llm/agents/image/generator.rb +455 -0
  115. data/lib/ruby_llm/agents/image/pipeline/dsl.rb +213 -0
  116. data/lib/ruby_llm/agents/image/pipeline/execution.rb +382 -0
  117. data/lib/ruby_llm/agents/image/pipeline.rb +97 -0
  118. data/lib/ruby_llm/agents/image/transformer/dsl.rb +148 -0
  119. data/lib/ruby_llm/agents/image/transformer/execution.rb +223 -0
  120. data/lib/ruby_llm/agents/image/transformer.rb +95 -0
  121. data/lib/ruby_llm/agents/image/upscaler/dsl.rb +83 -0
  122. data/lib/ruby_llm/agents/image/upscaler/execution.rb +219 -0
  123. data/lib/ruby_llm/agents/image/upscaler.rb +81 -0
  124. data/lib/ruby_llm/agents/image/variator/dsl.rb +62 -0
  125. data/lib/ruby_llm/agents/image/variator/execution.rb +189 -0
  126. data/lib/ruby_llm/agents/image/variator.rb +80 -0
  127. data/lib/ruby_llm/agents/{alert_manager.rb → infrastructure/alert_manager.rb} +17 -22
  128. data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +145 -0
  129. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +149 -0
  130. data/lib/ruby_llm/agents/infrastructure/budget/forecaster.rb +68 -0
  131. data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +279 -0
  132. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +275 -0
  133. data/lib/ruby_llm/agents/{execution_logger_job.rb → infrastructure/execution_logger_job.rb} +17 -1
  134. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/executor.rb +2 -1
  135. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/retry_strategy.rb +9 -3
  136. data/lib/ruby_llm/agents/{reliability.rb → infrastructure/reliability.rb} +11 -21
  137. data/lib/ruby_llm/agents/pipeline/builder.rb +215 -0
  138. data/lib/ruby_llm/agents/pipeline/context.rb +255 -0
  139. data/lib/ruby_llm/agents/pipeline/executor.rb +86 -0
  140. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +124 -0
  141. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +95 -0
  142. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +171 -0
  143. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +415 -0
  144. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +276 -0
  145. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +196 -0
  146. data/lib/ruby_llm/agents/pipeline.rb +68 -0
  147. data/lib/ruby_llm/agents/{engine.rb → rails/engine.rb} +79 -11
  148. data/lib/ruby_llm/agents/results/background_removal_result.rb +286 -0
  149. data/lib/ruby_llm/agents/{result.rb → results/base.rb} +73 -1
  150. data/lib/ruby_llm/agents/results/embedding_result.rb +243 -0
  151. data/lib/ruby_llm/agents/results/image_analysis_result.rb +314 -0
  152. data/lib/ruby_llm/agents/results/image_edit_result.rb +250 -0
  153. data/lib/ruby_llm/agents/results/image_generation_result.rb +346 -0
  154. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +399 -0
  155. data/lib/ruby_llm/agents/results/image_transform_result.rb +251 -0
  156. data/lib/ruby_llm/agents/results/image_upscale_result.rb +255 -0
  157. data/lib/ruby_llm/agents/results/image_variation_result.rb +237 -0
  158. data/lib/ruby_llm/agents/results/moderation_result.rb +158 -0
  159. data/lib/ruby_llm/agents/results/speech_result.rb +338 -0
  160. data/lib/ruby_llm/agents/results/transcription_result.rb +408 -0
  161. data/lib/ruby_llm/agents/text/embedder.rb +444 -0
  162. data/lib/ruby_llm/agents/text/moderator.rb +237 -0
  163. data/lib/ruby_llm/agents/workflow/async.rb +220 -0
  164. data/lib/ruby_llm/agents/workflow/async_executor.rb +156 -0
  165. data/lib/ruby_llm/agents/{workflow.rb → workflow/orchestrator.rb} +6 -5
  166. data/lib/ruby_llm/agents/workflow/parallel.rb +34 -17
  167. data/lib/ruby_llm/agents/workflow/thread_pool.rb +185 -0
  168. data/lib/ruby_llm/agents.rb +86 -20
  169. metadata +172 -34
  170. data/lib/ruby_llm/agents/base/caching.rb +0 -40
  171. data/lib/ruby_llm/agents/base/cost_calculation.rb +0 -105
  172. data/lib/ruby_llm/agents/base/dsl.rb +0 -324
  173. data/lib/ruby_llm/agents/base/execution.rb +0 -366
  174. data/lib/ruby_llm/agents/base/reliability_dsl.rb +0 -82
  175. data/lib/ruby_llm/agents/base/reliability_execution.rb +0 -136
  176. data/lib/ruby_llm/agents/base/response_building.rb +0 -86
  177. data/lib/ruby_llm/agents/base/tool_tracking.rb +0 -57
  178. data/lib/ruby_llm/agents/base.rb +0 -210
  179. data/lib/ruby_llm/agents/budget_tracker.rb +0 -733
  180. data/lib/ruby_llm/agents/configuration.rb +0 -394
  181. /data/lib/ruby_llm/agents/{deprecations.rb → core/deprecations.rb} +0 -0
  182. /data/lib/ruby_llm/agents/{inflections.rb → core/inflections.rb} +0 -0
  183. /data/lib/ruby_llm/agents/{resolved_config.rb → core/resolved_config.rb} +0 -0
  184. /data/lib/ruby_llm/agents/{attempt_tracker.rb → infrastructure/attempt_tracker.rb} +0 -0
  185. /data/lib/ruby_llm/agents/{cache_helper.rb → infrastructure/cache_helper.rb} +0 -0
  186. /data/lib/ruby_llm/agents/{circuit_breaker.rb → infrastructure/circuit_breaker.rb} +0 -0
  187. /data/lib/ruby_llm/agents/{redactor.rb → infrastructure/redactor.rb} +0 -0
  188. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/breaker_manager.rb +0 -0
  189. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/execution_constraints.rb +0 -0
  190. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/fallback_routing.rb +0 -0
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Controller for viewing workflow details and per-workflow analytics
6
+ #
7
+ # Provides detailed views for individual workflows including structure
8
+ # visualization, per-step/branch performance, route distribution,
9
+ # and execution history.
10
+ #
11
+ # @see AgentRegistry For workflow discovery
12
+ # @see Paginatable For pagination implementation
13
+ # @see Filterable For filter parsing and validation
14
+ # @api private
15
+ class WorkflowsController < ApplicationController
16
+ include Paginatable
17
+ include Filterable
18
+
19
+ # Shows detailed view for a specific workflow
20
+ #
21
+ # Loads workflow configuration (if class exists), statistics,
22
+ # filtered executions, chart data, and step-level analytics.
23
+ # Works for both active workflows and deleted workflows with history.
24
+ #
25
+ # @return [void]
26
+ def show
27
+ @workflow_type = CGI.unescape(params[:id])
28
+ @workflow_class = AgentRegistry.find(@workflow_type)
29
+ @workflow_active = @workflow_class.present?
30
+
31
+ # Determine workflow type from class or execution history
32
+ @workflow_type_kind = detect_workflow_type_kind
33
+
34
+ load_workflow_stats
35
+ load_filter_options
36
+ load_filtered_executions
37
+ load_chart_data
38
+ load_step_stats
39
+
40
+ if @workflow_class
41
+ load_workflow_config
42
+ end
43
+ rescue StandardError => e
44
+ Rails.logger.error("[RubyLLM::Agents] Error loading workflow #{@workflow_type}: #{e.message}")
45
+ redirect_to ruby_llm_agents.agents_path, alert: "Error loading workflow details"
46
+ end
47
+
48
+ private
49
+
50
+ # Detects the workflow type kind (pipeline, parallel, router)
51
+ #
52
+ # @return [String, nil] The workflow type kind
53
+ def detect_workflow_type_kind
54
+ if @workflow_class
55
+ ancestors = @workflow_class.ancestors.map { |a| a.name.to_s }
56
+ if ancestors.include?("RubyLLM::Agents::Workflow::Pipeline")
57
+ "pipeline"
58
+ elsif ancestors.include?("RubyLLM::Agents::Workflow::Parallel")
59
+ "parallel"
60
+ elsif ancestors.include?("RubyLLM::Agents::Workflow::Router")
61
+ "router"
62
+ end
63
+ else
64
+ # Fallback to execution history
65
+ Execution.by_agent(@workflow_type)
66
+ .where.not(workflow_type: nil)
67
+ .pluck(:workflow_type)
68
+ .first
69
+ end
70
+ end
71
+
72
+ # Loads all-time and today's statistics for the workflow
73
+ #
74
+ # @return [void]
75
+ def load_workflow_stats
76
+ @stats = Execution.stats_for(@workflow_type, period: :all_time)
77
+ @stats_today = Execution.stats_for(@workflow_type, period: :today)
78
+
79
+ # Additional stats for new schema fields
80
+ workflow_scope = Execution.by_agent(@workflow_type)
81
+ @cache_hit_rate = workflow_scope.cache_hit_rate
82
+ @streaming_rate = workflow_scope.streaming_rate
83
+ @avg_ttft = workflow_scope.avg_time_to_first_token
84
+ end
85
+
86
+ # Loads available filter options from execution history
87
+ #
88
+ # @return [void]
89
+ def load_filter_options
90
+ filter_data = Execution.by_agent(@workflow_type)
91
+ .where.not(agent_version: nil)
92
+ .or(Execution.by_agent(@workflow_type).where.not(model_id: nil))
93
+ .or(Execution.by_agent(@workflow_type).where.not(temperature: nil))
94
+ .pluck(:agent_version, :model_id, :temperature)
95
+
96
+ @versions = filter_data.map(&:first).compact.uniq.sort.reverse
97
+ @models = filter_data.map { |d| d[1] }.compact.uniq.sort
98
+ @temperatures = filter_data.map(&:last).compact.uniq.sort
99
+ end
100
+
101
+ # Loads paginated and filtered executions with statistics
102
+ #
103
+ # @return [void]
104
+ def load_filtered_executions
105
+ base_scope = build_filtered_scope
106
+ result = paginate(base_scope)
107
+ @executions = result[:records]
108
+ @pagination = result[:pagination]
109
+
110
+ @filter_stats = {
111
+ total_count: result[:pagination][:total_count],
112
+ total_cost: base_scope.sum(:total_cost),
113
+ total_tokens: base_scope.sum(:total_tokens)
114
+ }
115
+ end
116
+
117
+ # Builds a filtered scope for the current workflow's executions
118
+ #
119
+ # @return [ActiveRecord::Relation] Filtered execution scope
120
+ def build_filtered_scope
121
+ scope = Execution.by_agent(@workflow_type)
122
+
123
+ # Apply status filter with validation
124
+ statuses = parse_array_param(:statuses)
125
+ scope = apply_status_filter(scope, statuses) if statuses.any?
126
+
127
+ # Apply version filter
128
+ versions = parse_array_param(:versions)
129
+ scope = scope.where(agent_version: versions) if versions.any?
130
+
131
+ # Apply model filter
132
+ models = parse_array_param(:models)
133
+ scope = scope.where(model_id: models) if models.any?
134
+
135
+ # Apply temperature filter
136
+ temperatures = parse_array_param(:temperatures)
137
+ scope = scope.where(temperature: temperatures) if temperatures.any?
138
+
139
+ # Apply time range filter with validation
140
+ days = parse_days_param
141
+ scope = apply_time_filter(scope, days)
142
+
143
+ scope
144
+ end
145
+
146
+ # Loads chart data for workflow performance visualization
147
+ #
148
+ # @return [void]
149
+ def load_chart_data
150
+ @trend_data = Execution.trend_analysis(agent_type: @workflow_type, days: 30)
151
+ @status_distribution = Execution.by_agent(@workflow_type).group(:status).count
152
+ @finish_reason_distribution = Execution.by_agent(@workflow_type).finish_reason_distribution
153
+ end
154
+
155
+ # Loads per-step/branch statistics for workflow analytics
156
+ #
157
+ # @return [void]
158
+ def load_step_stats
159
+ @step_stats = calculate_step_stats
160
+ @route_distribution = calculate_route_distribution if @workflow_type_kind == "router"
161
+ end
162
+
163
+ # Calculates per-step/branch performance statistics
164
+ #
165
+ # @return [Array<Hash>] Array of step stats hashes
166
+ def calculate_step_stats
167
+ # Get root workflow executions
168
+ root_executions = Execution.by_agent(@workflow_type)
169
+ .root_executions
170
+ .where("created_at > ?", 30.days.ago)
171
+ .pluck(:id)
172
+
173
+ return [] if root_executions.empty?
174
+
175
+ # Aggregate child execution stats by workflow_step
176
+ child_stats = Execution.where(parent_execution_id: root_executions)
177
+ .group(:workflow_step)
178
+ .select(
179
+ "workflow_step",
180
+ "COUNT(*) as execution_count",
181
+ "AVG(duration_ms) as avg_duration_ms",
182
+ "SUM(total_cost) as total_cost",
183
+ "AVG(total_cost) as avg_cost",
184
+ "SUM(total_tokens) as total_tokens",
185
+ "AVG(total_tokens) as avg_tokens",
186
+ "SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count",
187
+ "SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_count"
188
+ )
189
+
190
+ # Get agent type mappings for each step
191
+ step_agent_map = Execution.where(parent_execution_id: root_executions)
192
+ .where.not(workflow_step: nil)
193
+ .group(:workflow_step)
194
+ .pluck(:workflow_step, Arel.sql("MAX(agent_type)"))
195
+ .to_h
196
+
197
+ child_stats.map do |row|
198
+ next if row.workflow_step.blank?
199
+
200
+ execution_count = row.execution_count.to_i
201
+ success_count = row.success_count.to_i
202
+ success_rate = execution_count > 0 ? (success_count.to_f / execution_count * 100).round(1) : 0
203
+
204
+ {
205
+ name: row.workflow_step,
206
+ agent_type: step_agent_map[row.workflow_step],
207
+ execution_count: execution_count,
208
+ success_rate: success_rate,
209
+ avg_duration_ms: row.avg_duration_ms.to_f.round(0),
210
+ total_cost: row.total_cost.to_f.round(4),
211
+ avg_cost: row.avg_cost.to_f.round(6),
212
+ total_tokens: row.total_tokens.to_i,
213
+ avg_tokens: row.avg_tokens.to_f.round(0)
214
+ }
215
+ end.compact
216
+ end
217
+
218
+ # Calculates route distribution for router workflows
219
+ #
220
+ # @return [Hash] Route distribution data
221
+ def calculate_route_distribution
222
+ # Get route distribution from routed_to field
223
+ distribution = Execution.by_agent(@workflow_type)
224
+ .where("created_at > ?", 30.days.ago)
225
+ .where.not(routed_to: nil)
226
+ .group(:routed_to)
227
+ .count
228
+
229
+ total = distribution.values.sum
230
+ return {} if total.zero?
231
+
232
+ # Add percentage and sorting
233
+ distribution.transform_values do |count|
234
+ {
235
+ count: count,
236
+ percentage: (count.to_f / total * 100).round(1)
237
+ }
238
+ end.sort_by { |_k, v| -v[:count] }.to_h
239
+ end
240
+
241
+ # Loads the current workflow class configuration
242
+ #
243
+ # @return [void]
244
+ def load_workflow_config
245
+ @config = {
246
+ # Basic configuration
247
+ version: safe_call(@workflow_class, :version),
248
+ description: safe_call(@workflow_class, :description),
249
+ timeout: safe_call(@workflow_class, :timeout),
250
+ max_cost: safe_call(@workflow_class, :max_cost)
251
+ }
252
+
253
+ # Workflow-specific configuration
254
+ case @workflow_type_kind
255
+ when "pipeline"
256
+ load_pipeline_config
257
+ when "parallel"
258
+ load_parallel_config
259
+ when "router"
260
+ load_router_config
261
+ end
262
+ end
263
+
264
+ # Loads pipeline-specific configuration
265
+ #
266
+ # @return [void]
267
+ def load_pipeline_config
268
+ @steps = extract_steps(@workflow_class)
269
+ @config[:steps_count] = @steps.size
270
+ end
271
+
272
+ # Loads parallel-specific configuration
273
+ #
274
+ # @return [void]
275
+ def load_parallel_config
276
+ @branches = extract_branches(@workflow_class)
277
+ @config[:branches_count] = @branches.size
278
+ @config[:fail_fast] = safe_call(@workflow_class, :fail_fast?)
279
+ end
280
+
281
+ # Loads router-specific configuration
282
+ #
283
+ # @return [void]
284
+ def load_router_config
285
+ @routes = extract_routes(@workflow_class)
286
+ @config[:routes_count] = @routes.size
287
+ @config[:classifier_model] = safe_call(@workflow_class, :classifier_model)
288
+ @config[:classifier_temperature] = safe_call(@workflow_class, :classifier_temperature)
289
+ end
290
+
291
+ # Extracts steps from a pipeline workflow class
292
+ #
293
+ # @param klass [Class] The workflow class
294
+ # @return [Array<Hash>] Array of step hashes
295
+ def extract_steps(klass)
296
+ return [] unless klass.respond_to?(:steps)
297
+
298
+ klass.steps.map do |name, config|
299
+ {
300
+ name: name,
301
+ agent: config[:agent]&.name,
302
+ optional: config[:continue_on_error] || false
303
+ }
304
+ end
305
+ end
306
+
307
+ # Extracts branches from a parallel workflow class
308
+ #
309
+ # @param klass [Class] The workflow class
310
+ # @return [Array<Hash>] Array of branch hashes
311
+ def extract_branches(klass)
312
+ return [] unless klass.respond_to?(:branches)
313
+
314
+ klass.branches.map do |name, config|
315
+ {
316
+ name: name,
317
+ agent: config[:agent]&.name,
318
+ optional: config[:optional] || false
319
+ }
320
+ end
321
+ end
322
+
323
+ # Extracts routes from a router workflow class
324
+ #
325
+ # @param klass [Class] The workflow class
326
+ # @return [Array<Hash>] Array of route hashes
327
+ def extract_routes(klass)
328
+ return [] unless klass.respond_to?(:routes)
329
+
330
+ klass.routes.map do |name, config|
331
+ {
332
+ name: name,
333
+ agent: config[:agent]&.name,
334
+ description: config[:description],
335
+ default: config[:default] || false
336
+ }
337
+ end
338
+ end
339
+
340
+ # Safely calls a method on a class, returning nil if method doesn't exist
341
+ #
342
+ # @param klass [Class, nil] The class to call the method on
343
+ # @param method_name [Symbol] The method to call
344
+ # @return [Object, nil] The result or nil
345
+ def safe_call(klass, method_name)
346
+ return nil unless klass
347
+ return nil unless klass.respond_to?(method_name)
348
+
349
+ klass.public_send(method_name)
350
+ rescue StandardError
351
+ nil
352
+ end
353
+ end
354
+ end
355
+ end
@@ -24,6 +24,31 @@ module RubyLLM
24
24
  RubyLLM::Agents::Engine.routes.url_helpers
25
25
  end
26
26
 
27
+ # Returns the URL for "All Tenants" (clears tenant filter)
28
+ #
29
+ # Handles two scenarios:
30
+ # 1. Query param routes - removes tenant_id from query params
31
+ # 2. Path-based tenant routes - navigates to equivalent global route
32
+ #
33
+ # @return [String] URL without tenant filtering
34
+ def all_tenants_url
35
+ # Map tenant-specific path routes to their global equivalents
36
+ tenant_route_mappings = {
37
+ "tenant" => ruby_llm_agents.api_configuration_path,
38
+ "edit_tenant" => ruby_llm_agents.edit_api_configuration_path
39
+ }
40
+
41
+ # Check if current action has a global equivalent
42
+ if tenant_route_mappings.key?(action_name)
43
+ base_path = tenant_route_mappings[action_name]
44
+ query = request.query_parameters.except("tenant_id")
45
+ query.any? ? "#{base_path}?#{query.to_query}" : base_path
46
+ else
47
+ # For query param routes, just remove tenant_id
48
+ url_for(request.query_parameters.except("tenant_id"))
49
+ end
50
+ end
51
+
27
52
  # Formats large numbers with human-readable suffixes (K, M, B)
28
53
  #
29
54
  # @param number [Numeric, nil] The number to format
@@ -72,6 +72,9 @@ module RubyLLM
72
72
  has_many :child_executions, class_name: "RubyLLM::Agents::Execution",
73
73
  foreign_key: :parent_execution_id, dependent: :nullify, inverse_of: :parent_execution
74
74
 
75
+ # Polymorphic association to tenant model (for llm_tenant DSL)
76
+ belongs_to :tenant_record, polymorphic: true, optional: true
77
+
75
78
  # Validations
76
79
  validates :agent_type, :model_id, :started_at, presence: true
77
80
  validates :status, inclusion: { in: statuses.keys }
@@ -6,7 +6,7 @@ module RubyLLM
6
6
  #
7
7
  # Stores per-tenant budget limits that override the global configuration.
8
8
  # Supports runtime updates without application restarts.
9
- # Supports both cost-based (USD) and token-based limits.
9
+ # Supports cost-based (USD), token-based, and execution-based limits.
10
10
  #
11
11
  # @!attribute [rw] tenant_id
12
12
  # @return [String] Unique identifier for the tenant
@@ -20,6 +20,10 @@ module RubyLLM
20
20
  # @return [Integer, nil] Daily token limit (across all models)
21
21
  # @!attribute [rw] monthly_token_limit
22
22
  # @return [Integer, nil] Monthly token limit (across all models)
23
+ # @!attribute [rw] daily_execution_limit
24
+ # @return [Integer, nil] Daily execution/call limit
25
+ # @!attribute [rw] monthly_execution_limit
26
+ # @return [Integer, nil] Monthly execution/call limit
23
27
  # @!attribute [rw] per_agent_daily
24
28
  # @return [Hash] Per-agent daily cost limits: { "AgentName" => limit }
25
29
  # @!attribute [rw] per_agent_monthly
@@ -28,25 +32,30 @@ module RubyLLM
28
32
  # @return [String] Enforcement mode: "none", "soft", or "hard"
29
33
  # @!attribute [rw] inherit_global_defaults
30
34
  # @return [Boolean] Whether to fall back to global config for unset limits
35
+ # @!attribute [rw] tenant_record
36
+ # @return [ActiveRecord::Base, nil] Polymorphic association to tenant model
31
37
  #
32
- # @example Creating a tenant budget with cost and token limits
38
+ # @example Creating a tenant budget with cost, token, and execution limits
33
39
  # TenantBudget.create!(
34
40
  # tenant_id: "acme_corp",
35
41
  # name: "Acme Corporation",
36
- # daily_limit: 50.0, # USD
37
- # monthly_limit: 500.0, # USD
42
+ # daily_limit: 50.0, # USD
43
+ # monthly_limit: 500.0, # USD
38
44
  # daily_token_limit: 1_000_000,
39
45
  # monthly_token_limit: 10_000_000,
40
- # per_agent_daily: { "ContentAgent" => 10.0 },
46
+ # daily_execution_limit: 500,
47
+ # monthly_execution_limit: 10_000,
41
48
  # enforcement: "hard"
42
49
  # )
43
50
  #
44
- # @example Fetching budget for a tenant
45
- # budget = TenantBudget.for_tenant("acme_corp")
46
- # budget.effective_daily_limit # => 50.0 (cost)
47
- # budget.effective_daily_token_limit # => 1_000_000 (tokens)
51
+ # @example Fetching budget for a tenant object
52
+ # budget = TenantBudget.for_tenant(organization)
53
+ # budget.effective_daily_limit # => 50.0 (cost)
54
+ # budget.effective_daily_token_limit # => 1_000_000 (tokens)
55
+ # budget.effective_daily_execution_limit # => 500 (executions)
48
56
  #
49
57
  # @see RubyLLM::Agents::BudgetTracker
58
+ # @see RubyLLM::Agents::LLMTenant
50
59
  # @api public
51
60
  class TenantBudget < ::ActiveRecord::Base
52
61
  self.table_name = "ruby_llm_agents_tenant_budgets"
@@ -54,6 +63,9 @@ module RubyLLM
54
63
  # Valid enforcement modes
55
64
  ENFORCEMENT_MODES = %w[none soft hard].freeze
56
65
 
66
+ # Polymorphic association to the tenant model (e.g., Organization, Account)
67
+ belongs_to :tenant_record, polymorphic: true, optional: true
68
+
57
69
  # Validations
58
70
  validates :tenant_id, presence: true, uniqueness: true
59
71
  validates :enforcement, inclusion: { in: ENFORCEMENT_MODES }, allow_nil: true
@@ -61,15 +73,23 @@ module RubyLLM
61
73
  numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
62
74
  validates :daily_token_limit, :monthly_token_limit,
63
75
  numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true
76
+ validates :daily_execution_limit, :monthly_execution_limit,
77
+ numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true
64
78
 
65
79
  # Finds a budget for the given tenant
66
80
  #
67
- # @param tenant_id [String] The tenant identifier
81
+ # @param tenant [String, Object] The tenant identifier string or object with llm_tenant_id
68
82
  # @return [TenantBudget, nil] The budget record or nil if not found
69
- def self.for_tenant(tenant_id)
70
- return nil if tenant_id.blank?
83
+ def self.for_tenant(tenant)
84
+ return nil if tenant.blank?
71
85
 
72
- find_by(tenant_id: tenant_id)
86
+ if tenant.respond_to?(:llm_tenant_id)
87
+ # Object with llm_tenant DSL - try polymorphic first, then tenant_id
88
+ find_by(tenant_record: tenant) || find_by(tenant_id: tenant.llm_tenant_id)
89
+ else
90
+ # String tenant_id
91
+ find_by(tenant_id: tenant.to_s)
92
+ end
73
93
  end
74
94
 
75
95
  # Finds or creates a budget for the given tenant
@@ -154,6 +174,26 @@ module RubyLLM
154
174
  global_config&.dig(:global_monthly_tokens)
155
175
  end
156
176
 
177
+ # Returns the effective daily execution limit, considering inheritance
178
+ #
179
+ # @return [Integer, nil] The daily execution limit or nil if not set
180
+ def effective_daily_execution_limit
181
+ return daily_execution_limit if daily_execution_limit.present?
182
+ return nil unless inherit_global_defaults
183
+
184
+ global_config&.dig(:global_daily_executions)
185
+ end
186
+
187
+ # Returns the effective monthly execution limit, considering inheritance
188
+ #
189
+ # @return [Integer, nil] The monthly execution limit or nil if not set
190
+ def effective_monthly_execution_limit
191
+ return monthly_execution_limit if monthly_execution_limit.present?
192
+ return nil unless inherit_global_defaults
193
+
194
+ global_config&.dig(:global_monthly_executions)
195
+ end
196
+
157
197
  # Returns the effective enforcement mode
158
198
  #
159
199
  # @return [Symbol] :none, :soft, or :hard
@@ -183,9 +223,12 @@ module RubyLLM
183
223
  global_monthly: effective_monthly_limit,
184
224
  per_agent_daily: merged_per_agent_daily,
185
225
  per_agent_monthly: merged_per_agent_monthly,
186
- # Token limits (global only)
226
+ # Token limits
187
227
  global_daily_tokens: effective_daily_token_limit,
188
- global_monthly_tokens: effective_monthly_token_limit
228
+ global_monthly_tokens: effective_monthly_token_limit,
229
+ # Execution limits
230
+ global_daily_executions: effective_daily_execution_limit,
231
+ global_monthly_executions: effective_monthly_execution_limit
189
232
  }
190
233
  end
191
234
 
@@ -60,12 +60,19 @@ module RubyLLM
60
60
  #
61
61
  # @return [Array<String>] Agent class names
62
62
  def file_system_agents
63
- # Ensure all agent classes are loaded
63
+ # Ensure all agent and workflow classes are loaded
64
64
  eager_load_agents!
65
65
 
66
- # Find all descendants of the base class
67
- base_class = RubyLLM::Agents::Base
68
- base_class.descendants.map(&:name).compact
66
+ # Find all descendants of all base classes
67
+ agents = RubyLLM::Agents::Base.descendants.map(&:name).compact
68
+ workflows = RubyLLM::Agents::Workflow.descendants.map(&:name).compact
69
+ embedders = RubyLLM::Agents::Embedder.descendants.map(&:name).compact
70
+ moderators = RubyLLM::Agents::Moderator.descendants.map(&:name).compact
71
+ speakers = RubyLLM::Agents::Speaker.descendants.map(&:name).compact
72
+ transcribers = RubyLLM::Agents::Transcriber.descendants.map(&:name).compact
73
+ image_generators = RubyLLM::Agents::ImageGenerator.descendants.map(&:name).compact
74
+
75
+ (agents + workflows + embedders + moderators + speakers + transcribers + image_generators).uniq
69
76
  rescue StandardError => e
70
77
  Rails.logger.error("[RubyLLM::Agents] Error loading agents from file system: #{e.message}")
71
78
  []
@@ -81,17 +88,19 @@ module RubyLLM
81
88
  []
82
89
  end
83
90
 
84
- # Eager loads all agent files to register descendants
91
+ # Eager loads all agent and workflow files to register descendants
85
92
  #
86
93
  # @return [void]
87
94
  def eager_load_agents!
88
- agents_path = Rails.root.join("app", "agents")
89
- return unless agents_path.exist?
95
+ %w[agents workflows embedders moderators speakers transcribers image_generators].each do |dir|
96
+ path = Rails.root.join("app", dir)
97
+ next unless path.exist?
90
98
 
91
- Dir.glob(agents_path.join("**", "*.rb")).each do |file|
92
- require_dependency file
93
- rescue LoadError, StandardError => e
94
- Rails.logger.error("[RubyLLM::Agents] Failed to load agent file #{file}: #{e.message}")
99
+ Dir.glob(path.join("**", "*.rb")).each do |file|
100
+ require_dependency file
101
+ rescue LoadError, StandardError => e
102
+ Rails.logger.error("[RubyLLM::Agents] Failed to load file #{file}: #{e.message}")
103
+ end
95
104
  end
96
105
  end
97
106
 
@@ -125,8 +134,11 @@ module RubyLLM
125
134
  agent_class = find(agent_type)
126
135
  stats = fetch_stats(agent_type)
127
136
 
137
+ # Detect the agent type (agent, workflow, embedder, moderator, speaker, transcriber)
138
+ detected_type = detect_agent_type(agent_class)
139
+
128
140
  # Check if this is a workflow class vs a regular agent
129
- is_workflow = agent_class&.ancestors&.any? { |a| a.name&.include?("Workflow") }
141
+ is_workflow = detected_type == "workflow"
130
142
 
131
143
  # Determine specific workflow type and children
132
144
  workflow_type = is_workflow ? detect_workflow_type(agent_class) : nil
@@ -136,6 +148,7 @@ module RubyLLM
136
148
  name: agent_type,
137
149
  class: agent_class,
138
150
  active: agent_class.present?,
151
+ agent_type: detected_type,
139
152
  is_workflow: is_workflow,
140
153
  workflow_type: workflow_type,
141
154
  workflow_children: workflow_children,
@@ -209,6 +222,32 @@ module RubyLLM
209
222
  end
210
223
  end
211
224
 
225
+ # Detects the agent type from class hierarchy
226
+ #
227
+ # @param agent_class [Class, nil] The agent class
228
+ # @return [String] "agent", "workflow", "embedder", "moderator", "speaker", "transcriber", or "image_generator"
229
+ def detect_agent_type(agent_class)
230
+ return "agent" unless agent_class
231
+
232
+ ancestors = agent_class.ancestors.map { |a| a.name.to_s }
233
+
234
+ if ancestors.include?("RubyLLM::Agents::Embedder")
235
+ "embedder"
236
+ elsif ancestors.include?("RubyLLM::Agents::Moderator")
237
+ "moderator"
238
+ elsif ancestors.include?("RubyLLM::Agents::Speaker")
239
+ "speaker"
240
+ elsif ancestors.include?("RubyLLM::Agents::Transcriber")
241
+ "transcriber"
242
+ elsif ancestors.include?("RubyLLM::Agents::ImageGenerator")
243
+ "image_generator"
244
+ elsif ancestors.include?("RubyLLM::Agents::Workflow")
245
+ "workflow"
246
+ else
247
+ "agent"
248
+ end
249
+ end
250
+
212
251
  # Extracts child agents from workflow DSL configuration
213
252
  #
214
253
  # @param agent_class [Class, nil] The workflow class