ruby_llm-agents 1.0.0 → 1.2.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +9 -3
  3. data/app/controllers/concerns/ruby_llm/agents/sortable.rb +58 -0
  4. data/app/controllers/ruby_llm/agents/agents_controller.rb +59 -16
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +144 -20
  6. data/app/controllers/ruby_llm/agents/executions_controller.rb +13 -16
  7. data/app/controllers/ruby_llm/agents/workflows_controller.rb +279 -90
  8. data/app/helpers/ruby_llm/agents/application_helper.rb +100 -0
  9. data/app/mailers/ruby_llm/agents/alert_mailer.rb +84 -0
  10. data/app/mailers/ruby_llm/agents/application_mailer.rb +28 -0
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +170 -20
  12. data/app/models/ruby_llm/agents/execution/scopes.rb +0 -31
  13. data/app/models/ruby_llm/agents/execution/workflow.rb +0 -129
  14. data/app/models/ruby_llm/agents/execution.rb +50 -14
  15. data/app/models/ruby_llm/agents/tenant/budgetable.rb +277 -0
  16. data/app/models/ruby_llm/agents/tenant/configurable.rb +135 -0
  17. data/app/models/ruby_llm/agents/tenant/trackable.rb +310 -0
  18. data/app/models/ruby_llm/agents/tenant.rb +146 -0
  19. data/app/models/ruby_llm/agents/tenant_budget.rb +12 -253
  20. data/app/services/ruby_llm/agents/agent_registry.rb +18 -12
  21. data/app/views/layouts/ruby_llm/agents/application.html.erb +72 -76
  22. data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -12
  23. data/app/views/ruby_llm/agents/agents/_sortable_header.html.erb +56 -0
  24. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +5 -15
  25. data/app/views/ruby_llm/agents/agents/index.html.erb +271 -100
  26. data/app/views/ruby_llm/agents/agents/show.html.erb +1 -0
  27. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +107 -0
  28. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +18 -0
  29. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +4 -1
  30. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +66 -359
  31. data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +56 -0
  32. data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +115 -0
  33. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +35 -60
  34. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +17 -6
  35. data/app/views/ruby_llm/agents/dashboard/index.html.erb +373 -72
  36. data/app/views/ruby_llm/agents/executions/_execution.html.erb +0 -1
  37. data/app/views/ruby_llm/agents/executions/_filters.html.erb +51 -39
  38. data/app/views/ruby_llm/agents/executions/_list.html.erb +53 -195
  39. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +5 -20
  40. data/app/views/ruby_llm/agents/executions/index.html.erb +7 -83
  41. data/app/views/ruby_llm/agents/executions/show.html.erb +10 -20
  42. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +2 -1
  43. data/app/views/ruby_llm/agents/shared/_doc_link.html.erb +12 -0
  44. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +3 -15
  45. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +1 -1
  46. data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +1 -1
  47. data/app/views/ruby_llm/agents/shared/_sortable_header.html.erb +53 -0
  48. data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +7 -0
  49. data/app/views/ruby_llm/agents/shared/_status_dot.html.erb +1 -1
  50. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +9 -35
  51. data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -1
  52. data/app/views/ruby_llm/agents/tenants/index.html.erb +4 -1
  53. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +7 -15
  54. data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +539 -0
  55. data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +920 -0
  56. data/app/views/ruby_llm/agents/workflows/index.html.erb +179 -0
  57. data/app/views/ruby_llm/agents/workflows/show.html.erb +164 -139
  58. data/config/routes.rb +1 -1
  59. data/lib/generators/ruby_llm_agents/agent_generator.rb +6 -36
  60. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +7 -37
  61. data/lib/generators/ruby_llm_agents/embedder_generator.rb +5 -38
  62. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -37
  63. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +7 -37
  64. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +8 -41
  65. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +18 -46
  66. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +7 -37
  67. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +7 -37
  68. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +7 -37
  69. data/lib/generators/ruby_llm_agents/install_generator.rb +33 -56
  70. data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +480 -0
  71. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +42 -22
  72. data/lib/generators/ruby_llm_agents/restructure_generator.rb +2 -2
  73. data/lib/generators/ruby_llm_agents/speaker_generator.rb +8 -39
  74. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +13 -2
  75. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +5 -8
  76. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +40 -42
  77. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +20 -22
  78. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +24 -26
  79. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +20 -22
  80. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +19 -17
  81. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +31 -33
  82. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +125 -127
  83. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +20 -18
  84. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +19 -17
  85. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +19 -17
  86. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +38 -40
  87. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +42 -44
  88. data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +48 -0
  89. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +19 -21
  90. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +11 -0
  91. data/lib/generators/ruby_llm_agents/templates/create_tenants_migration.rb.tt +72 -0
  92. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +19 -21
  93. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +20 -22
  94. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +15 -17
  95. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +25 -27
  96. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +19 -21
  97. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +20 -22
  98. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +17 -19
  99. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +15 -17
  100. data/lib/generators/ruby_llm_agents/templates/rename_tenant_budgets_to_tenants_migration.rb.tt +34 -0
  101. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +87 -24
  102. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +21 -27
  103. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +46 -54
  104. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +31 -39
  105. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +22 -28
  106. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +53 -63
  107. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +46 -56
  108. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +23 -31
  109. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +22 -30
  110. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +23 -31
  111. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +38 -46
  112. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +7 -7
  113. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +59 -71
  114. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +274 -23
  115. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +29 -31
  116. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +28 -30
  117. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +10 -43
  118. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +26 -0
  119. data/lib/ruby_llm/agents/core/configuration.rb +55 -43
  120. data/lib/ruby_llm/agents/core/llm_tenant.rb +60 -60
  121. data/lib/ruby_llm/agents/core/version.rb +1 -1
  122. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +26 -0
  123. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +4 -2
  124. data/lib/ruby_llm/agents/pipeline.rb +69 -0
  125. data/lib/ruby_llm/agents/workflow/approval.rb +205 -0
  126. data/lib/ruby_llm/agents/workflow/approval_store.rb +179 -0
  127. data/lib/ruby_llm/agents/workflow/dsl/executor.rb +467 -0
  128. data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +244 -0
  129. data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +289 -0
  130. data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +107 -0
  131. data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +150 -0
  132. data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +187 -0
  133. data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +352 -0
  134. data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +415 -0
  135. data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +257 -0
  136. data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +317 -0
  137. data/lib/ruby_llm/agents/workflow/dsl.rb +576 -0
  138. data/lib/ruby_llm/agents/workflow/instrumentation.rb +2 -7
  139. data/lib/ruby_llm/agents/workflow/notifiers/base.rb +117 -0
  140. data/lib/ruby_llm/agents/workflow/notifiers/email.rb +117 -0
  141. data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +180 -0
  142. data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +121 -0
  143. data/lib/ruby_llm/agents/workflow/notifiers.rb +70 -0
  144. data/lib/ruby_llm/agents/workflow/orchestrator.rb +190 -23
  145. data/lib/ruby_llm/agents/workflow/result.rb +202 -0
  146. data/lib/ruby_llm/agents/workflow/throttle_manager.rb +206 -0
  147. data/lib/ruby_llm/agents/workflow/wait_result.rb +213 -0
  148. metadata +43 -6
  149. data/app/views/ruby_llm/agents/dashboard/_execution_item.html.erb +0 -66
  150. data/lib/ruby_llm/agents/workflow/parallel.rb +0 -299
  151. data/lib/ruby_llm/agents/workflow/pipeline.rb +0 -306
  152. data/lib/ruby_llm/agents/workflow/router.rb +0 -429
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c1293e62ffc0a0799831f2327238fe2929cc3c72aaf036f515b92133b8dcd70
4
- data.tar.gz: 885598b5c419ed497e118559ef79513df1b4ad85fcc16ce26356ebf5d365a02d
3
+ metadata.gz: a14dc618496842dd6db2fc197ab5c4f4190fb635b1bad8765a0983cc00e2550d
4
+ data.tar.gz: 22132d6e59964206dccb41db60dd3a9939217d47d6925074523bc1027173f4ed
5
5
  SHA512:
6
- metadata.gz: ef4a8745a5b182721cecb6cdc56656b4aa6a6fee25cef2cb82d208385fcee9cacfd8f5b23897d1332ec7fb1cdc0e44696bff34659f4b9cef39807e267e07d767
7
- data.tar.gz: 3736fbc6e8106a9e0a0eb6a56c9b9b1ae0e567520bb63622ad027449940fe465c2f1f101b05d4c83874f506119ed326db661313649dc52adfed69d6e69c28550
6
+ metadata.gz: c4936cf3773b4864bb9d1dd8031fb23e8321435bc4c1730ac088f299cc60f5a88992496b317f3391a131a303a6c82666ca7c4d1b77e4ea7da2f8c033e01a8c43
7
+ data.tar.gz: ca34cddc439b2f80652f9748dc62bd5fa1d365feac68101f7567e15f1bc5c2fef4dcec97e552b1dde7a20744782143c1ef5bffc2c7aaa97083b7d2f36bcf600c
@@ -21,6 +21,7 @@ module RubyLLM
21
21
  #
22
22
  # @param scope [ActiveRecord::Relation] The scope to paginate
23
23
  # @param ordered [Boolean] Whether to apply default descending order (default: true)
24
+ # @param sort_params [Hash, nil] Optional custom sort parameters with :column and :direction
24
25
  # @return [Hash] Contains :records and :pagination keys
25
26
  # @option return [ActiveRecord::Relation] :records Paginated records
26
27
  # @option return [Hash] :pagination Pagination metadata
@@ -28,13 +29,18 @@ module RubyLLM
28
29
  # - :per_page [Integer] Records per page
29
30
  # - :total_count [Integer] Total record count
30
31
  # - :total_pages [Integer] Total page count
31
- def paginate(scope, ordered: true)
32
+ def paginate(scope, ordered: true, sort_params: nil)
32
33
  page = [(params[:page] || 1).to_i, 1].max
33
34
  per_page = RubyLLM::Agents.configuration.per_page
34
35
  offset = (page - 1) * per_page
35
36
 
36
- # Qualify column name to avoid ambiguity when joins are present
37
- scope = scope.order("#{scope.model.table_name}.created_at DESC") if ordered
37
+ # Apply sorting - use custom sort_params if provided, otherwise default
38
+ table_name = scope.model.table_name
39
+ if sort_params.present?
40
+ scope = scope.order("#{table_name}.#{sort_params[:column]} #{sort_params[:direction].upcase}")
41
+ elsif ordered
42
+ scope = scope.order("#{table_name}.created_at DESC")
43
+ end
38
44
  total_count = scope.count
39
45
 
40
46
  {
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Controller concern for sorting
6
+ #
7
+ # Provides secure column sorting with whitelisted columns and direction validation.
8
+ # Prevents SQL injection by only allowing predefined sort columns.
9
+ #
10
+ # @example Using in a controller
11
+ # include Sortable
12
+ # @sort_params = parse_sort_params
13
+ # result = paginate(scope, sort_params: @sort_params)
14
+ #
15
+ # @api private
16
+ module Sortable
17
+ extend ActiveSupport::Concern
18
+
19
+ # Whitelist of allowed sort columns mapped to their database column names
20
+ # Keys are the URL parameter values, values are the actual column names
21
+ SORTABLE_COLUMNS = {
22
+ "agent_type" => "agent_type",
23
+ "status" => "status",
24
+ "model_id" => "model_id",
25
+ "agent_version" => "agent_version",
26
+ "total_tokens" => "total_tokens",
27
+ "total_cost" => "total_cost",
28
+ "duration_ms" => "duration_ms",
29
+ "created_at" => "created_at"
30
+ }.freeze
31
+
32
+ SORT_DIRECTIONS = %w[asc desc].freeze
33
+ DEFAULT_SORT_COLUMN = "created_at"
34
+ DEFAULT_SORT_DIRECTION = "desc"
35
+
36
+ private
37
+
38
+ # Parses and validates sort parameters from the request
39
+ #
40
+ # Returns validated sort column and direction, falling back to defaults
41
+ # if invalid values are provided. This prevents SQL injection by only
42
+ # allowing whitelisted column names.
43
+ #
44
+ # @return [Hash] Contains :column and :direction keys
45
+ # @option return [String] :column The validated database column name
46
+ # @option return [String] :direction Either 'asc' or 'desc'
47
+ def parse_sort_params
48
+ column = params[:sort].to_s
49
+ direction = params[:direction].to_s.downcase
50
+
51
+ {
52
+ column: SORTABLE_COLUMNS[column] || DEFAULT_SORT_COLUMN,
53
+ direction: SORT_DIRECTIONS.include?(direction) ? direction : DEFAULT_SORT_DIRECTION
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -16,21 +16,35 @@ module RubyLLM
16
16
  include Paginatable
17
17
  include Filterable
18
18
 
19
+ # Allowed sort columns for the agents list (in-memory sorting)
20
+ AGENT_SORTABLE_COLUMNS = %w[name agent_type model execution_count total_cost success_rate last_executed].freeze
21
+ DEFAULT_AGENT_SORT_COLUMN = "name"
22
+ DEFAULT_AGENT_SORT_DIRECTION = "asc"
23
+
19
24
  # Lists all registered agents with their details
20
25
  #
21
26
  # Uses AgentRegistry to discover agents from both file system
22
27
  # and execution history, ensuring deleted agents with history
23
28
  # are still visible. Separates agents and workflows for tabbed display.
29
+ # Deleted agents are shown in a separate tab.
24
30
  #
25
31
  # @return [void]
26
32
  def index
27
33
  all_items = AgentRegistry.all_with_details
28
34
 
29
- # Separate agents and workflows
30
- @agents = all_items.reject { |a| a[:is_workflow] }
31
- @workflows = all_items.select { |a| a[:is_workflow] }
35
+ # Filter to only agents (not workflows)
36
+ all_agents = all_items.reject { |a| a[:is_workflow] }
37
+
38
+ # Separate active and deleted agents
39
+ @agents = all_agents.select { |a| a[:active] }
40
+ @deleted_agents = all_agents.reject { |a| a[:active] }
32
41
 
33
- # Group agents by type for sub-tabs
42
+ # Parse and apply sorting to both lists
43
+ @sort_params = parse_agent_sort_params
44
+ @agents = sort_agents(@agents)
45
+ @deleted_agents = sort_agents(@deleted_agents)
46
+
47
+ # Group active agents by type for sub-tabs
34
48
  @agents_by_type = {
35
49
  agent: @agents.select { |a| a[:agent_type] == "agent" },
36
50
  embedder: @agents.select { |a| a[:agent_type] == "embedder" },
@@ -40,24 +54,16 @@ module RubyLLM
40
54
  image_generator: @agents.select { |a| a[:agent_type] == "image_generator" }
41
55
  }
42
56
 
43
- # Group workflows by type for sub-tabs
44
- @workflows_by_type = {
45
- pipeline: @workflows.select { |w| w[:workflow_type] == "pipeline" },
46
- parallel: @workflows.select { |w| w[:workflow_type] == "parallel" },
47
- router: @workflows.select { |w| w[:workflow_type] == "router" }
48
- }
49
-
50
- # Counts for tab badges
51
57
  @agent_count = @agents.size
52
- @workflow_count = @workflows.size
58
+ @deleted_count = @deleted_agents.size
53
59
  rescue StandardError => e
54
60
  Rails.logger.error("[RubyLLM::Agents] Error loading agents: #{e.message}")
55
61
  @agents = []
56
- @workflows = []
62
+ @deleted_agents = []
57
63
  @agents_by_type = { agent: [], embedder: [], moderator: [], speaker: [], transcriber: [], image_generator: [] }
58
- @workflows_by_type = { pipeline: [], parallel: [], router: [] }
59
64
  @agent_count = 0
60
- @workflow_count = 0
65
+ @deleted_count = 0
66
+ @sort_params = { column: DEFAULT_AGENT_SORT_COLUMN, direction: DEFAULT_AGENT_SORT_DIRECTION }
61
67
  flash.now[:alert] = "Error loading agents list"
62
68
  end
63
69
 
@@ -404,6 +410,43 @@ module RubyLLM
404
410
  Rails.logger.debug("[RubyLLM::Agents] Could not load circuit breaker status: #{e.message}")
405
411
  @circuit_breaker_status = {}
406
412
  end
413
+
414
+ # Parses and validates sort parameters for agents list
415
+ #
416
+ # @return [Hash] Contains :column and :direction keys
417
+ def parse_agent_sort_params
418
+ column = params[:sort].to_s
419
+ direction = params[:direction].to_s.downcase
420
+
421
+ {
422
+ column: AGENT_SORTABLE_COLUMNS.include?(column) ? column : DEFAULT_AGENT_SORT_COLUMN,
423
+ direction: %w[asc desc].include?(direction) ? direction : DEFAULT_AGENT_SORT_DIRECTION
424
+ }
425
+ end
426
+
427
+ # Sorts agents array based on sort params
428
+ #
429
+ # @param agents [Array<Hash>] Array of agent hashes
430
+ # @return [Array<Hash>] Sorted array
431
+ def sort_agents(agents)
432
+ column = @sort_params[:column].to_sym
433
+ direction = @sort_params[:direction]
434
+
435
+ sorted = agents.sort_by do |agent|
436
+ value = agent[column]
437
+ # Handle nil values - put them at the end
438
+ case column
439
+ when :last_executed
440
+ value || Time.at(0)
441
+ when :execution_count, :total_cost, :success_rate
442
+ value || 0
443
+ else
444
+ value.to_s.downcase
445
+ end
446
+ end
447
+
448
+ direction == "desc" ? sorted.reverse : sorted
449
+ end
407
450
  end
408
451
  end
409
452
  end
@@ -27,32 +27,92 @@ module RubyLLM
27
27
  @agent_stats = build_agent_comparison(base_scope)
28
28
  @top_errors = build_top_errors(base_scope)
29
29
  @tenant_budget = load_tenant_budget(base_scope)
30
+ @model_stats = build_model_stats(base_scope)
30
31
  end
31
32
 
32
33
  # Returns chart data as JSON for live updates
33
34
  #
34
- # @param range [String] Time range: "today", "7d", or "30d"
35
- # @return [JSON] Chart data with series
35
+ # @param range [String] Time range: "today", "7d", "30d", "60d", "90d", or custom "YYYY-MM-DD_YYYY-MM-DD"
36
+ # @param compare [String] If "true", include comparison data from previous period
37
+ # @return [JSON] Chart data with series (and optional comparison series)
36
38
  def chart_data
37
39
  range = params[:range].presence || "today"
38
- render json: tenant_scoped_executions.activity_chart_json(range: range)
40
+ compare = params[:compare] == "true"
41
+
42
+ if custom_range?(range)
43
+ from_date, to_date = parse_custom_range(range)
44
+ data = tenant_scoped_executions.activity_chart_json_for_dates(from: from_date, to: to_date)
45
+ else
46
+ data = tenant_scoped_executions.activity_chart_json(range: range)
47
+ end
48
+
49
+ if compare
50
+ offset_days = range_to_days(range)
51
+ comparison_data = if custom_range?(range)
52
+ from_date, to_date = parse_custom_range(range)
53
+ tenant_scoped_executions.activity_chart_json_for_dates(
54
+ from: from_date - offset_days.days,
55
+ to: to_date - offset_days.days
56
+ )
57
+ else
58
+ tenant_scoped_executions.activity_chart_json(
59
+ range: range,
60
+ offset_days: offset_days
61
+ )
62
+ end
63
+ data[:comparison] = comparison_data
64
+ end
65
+
66
+ render json: data
39
67
  end
40
68
 
41
69
  private
42
70
 
43
71
  # Converts range parameter to number of days
44
72
  #
45
- # @param range [String] Range parameter (today, 7d, 30d)
73
+ # @param range [String] Range parameter (today, 7d, 30d, 60d, 90d, or custom YYYY-MM-DD_YYYY-MM-DD)
46
74
  # @return [Integer] Number of days
47
75
  def range_to_days(range)
48
76
  case range
49
77
  when "today" then 1
50
78
  when "7d" then 7
51
79
  when "30d" then 30
52
- else 1
80
+ when "60d" then 60
81
+ when "90d" then 90
82
+ else
83
+ # Handle custom range format "YYYY-MM-DD_YYYY-MM-DD"
84
+ if range&.include?("_")
85
+ from_str, to_str = range.split("_")
86
+ from_date = Date.parse(from_str) rescue nil
87
+ to_date = Date.parse(to_str) rescue nil
88
+ if from_date && to_date
89
+ (to_date - from_date).to_i + 1
90
+ else
91
+ 1
92
+ end
93
+ else
94
+ 1
95
+ end
53
96
  end
54
97
  end
55
98
 
99
+ # Checks if a range is a custom date range
100
+ #
101
+ # @param range [String] Range parameter
102
+ # @return [Boolean] True if custom date range format
103
+ def custom_range?(range)
104
+ range&.match?(/\A\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\z/)
105
+ end
106
+
107
+ # Parses a custom range string into date objects
108
+ #
109
+ # @param range [String] Custom range in format "YYYY-MM-DD_YYYY-MM-DD"
110
+ # @return [Array<Date>] [from_date, to_date]
111
+ def parse_custom_range(range)
112
+ from_str, to_str = range.split("_")
113
+ [Date.parse(from_str), Date.parse(to_str)]
114
+ end
115
+
56
116
  # Builds per-agent comparison statistics for all agent types
57
117
  #
58
118
  # Creates separate instance variables for each agent type:
@@ -68,33 +128,35 @@ module RubyLLM
68
128
  # @return [Array<Hash>] Array of base agent stats (for backward compatibility)
69
129
  def build_agent_comparison(base_scope = Execution)
70
130
  scope = base_scope.last_n_days(@days)
71
- agent_types = scope.distinct.pluck(:agent_type)
72
131
 
73
- all_stats = agent_types.map do |agent_type|
74
- agent_scope = scope.where(agent_type: agent_type)
75
- count = agent_scope.count
76
- total_cost = agent_scope.sum(:total_cost) || 0
77
- successful = agent_scope.successful.count
132
+ # Get ALL agents from registry (file system + execution history)
133
+ all_agent_types = AgentRegistry.all
134
+
135
+ # Batch fetch stats for executed agents (4 queries total)
136
+ execution_stats = batch_fetch_agent_stats(scope)
78
137
 
79
- # Detect agent type using AgentRegistry
138
+ all_stats = all_agent_types.map do |agent_type|
80
139
  agent_class = AgentRegistry.find(agent_type)
81
140
  detected_type = AgentRegistry.send(:detect_agent_type, agent_class)
82
-
83
- # Get workflow type if applicable
84
141
  workflow_type = detected_type == "workflow" ? detect_workflow_type(agent_class) : nil
85
142
 
143
+ # Get stats from batch or use zeros for never-executed agents
144
+ stats = execution_stats[agent_type] || {
145
+ count: 0, total_cost: 0, avg_cost: 0, avg_duration_ms: 0, success_rate: 0
146
+ }
147
+
86
148
  {
87
149
  agent_type: agent_type,
88
150
  detected_type: detected_type,
89
- executions: count,
90
- total_cost: total_cost,
91
- avg_cost: count > 0 ? (total_cost / count).round(6) : 0,
92
- avg_duration_ms: agent_scope.average(:duration_ms)&.round || 0,
93
- success_rate: count > 0 ? (successful.to_f / count * 100).round(1) : 0,
151
+ executions: stats[:count],
152
+ total_cost: stats[:total_cost],
153
+ avg_cost: stats[:avg_cost],
154
+ avg_duration_ms: stats[:avg_duration_ms],
155
+ success_rate: stats[:success_rate],
94
156
  is_workflow: detected_type == "workflow",
95
157
  workflow_type: workflow_type
96
158
  }
97
- end.sort_by { |a| -(a[:total_cost] || 0) }
159
+ end.sort_by { |a| [-(a[:executions] || 0), -(a[:total_cost] || 0)] }
98
160
 
99
161
  # Split stats by agent type for 7-tab display
100
162
  @agent_stats = all_stats.select { |a| a[:detected_type] == "agent" }
@@ -127,6 +189,42 @@ module RubyLLM
127
189
  end
128
190
  end
129
191
 
192
+ # Builds per-model statistics for model comparison and cost breakdown
193
+ #
194
+ # @param base_scope [ActiveRecord::Relation] Base scope to filter from
195
+ # @return [Array<Hash>] Array of model stats sorted by total cost descending
196
+ def build_model_stats(base_scope = Execution)
197
+ scope = base_scope.last_n_days(@days).where.not(model_id: nil)
198
+
199
+ # Batch fetch stats grouped by model
200
+ counts = scope.group(:model_id).count
201
+ costs = scope.group(:model_id).sum(:total_cost)
202
+ tokens = scope.group(:model_id).sum(:total_tokens)
203
+ durations = scope.group(:model_id).average(:duration_ms)
204
+ success_counts = scope.successful.group(:model_id).count
205
+
206
+ total_cost = costs.values.sum
207
+
208
+ model_ids = counts.keys
209
+ model_ids.map do |model_id|
210
+ count = counts[model_id] || 0
211
+ model_cost = costs[model_id] || 0
212
+ model_tokens = tokens[model_id] || 0
213
+ successful = success_counts[model_id] || 0
214
+
215
+ {
216
+ model_id: model_id,
217
+ executions: count,
218
+ total_cost: model_cost,
219
+ total_tokens: model_tokens,
220
+ avg_duration_ms: durations[model_id]&.round || 0,
221
+ success_rate: count > 0 ? (successful.to_f / count * 100).round(1) : 0,
222
+ cost_per_1k_tokens: model_tokens > 0 ? (model_cost / model_tokens * 1000).round(4) : 0,
223
+ cost_percentage: total_cost > 0 ? (model_cost / total_cost * 100).round(1) : 0
224
+ }
225
+ end.sort_by { |m| -(m[:total_cost] || 0) }
226
+ end
227
+
130
228
  # Builds top errors list
131
229
  #
132
230
  # @param base_scope [ActiveRecord::Relation] Base scope to filter from
@@ -332,6 +430,32 @@ module RubyLLM
332
430
 
333
431
  alerts.take(3)
334
432
  end
433
+
434
+ # Batch fetches execution stats for all agents in a time period
435
+ #
436
+ # @param scope [ActiveRecord::Relation] Base scope with time filter
437
+ # @return [Hash<String, Hash>] Agent type => stats hash
438
+ def batch_fetch_agent_stats(scope)
439
+ counts = scope.group(:agent_type).count
440
+ costs = scope.group(:agent_type).sum(:total_cost)
441
+ success_counts = scope.successful.group(:agent_type).count
442
+ durations = scope.group(:agent_type).average(:duration_ms)
443
+
444
+ agent_types = (counts.keys + costs.keys).uniq
445
+ agent_types.each_with_object({}) do |agent_type, hash|
446
+ count = counts[agent_type] || 0
447
+ total_cost = costs[agent_type] || 0
448
+ successful = success_counts[agent_type] || 0
449
+
450
+ hash[agent_type] = {
451
+ count: count,
452
+ total_cost: total_cost,
453
+ avg_cost: count > 0 ? (total_cost / count).round(6) : 0,
454
+ avg_duration_ms: durations[agent_type]&.round || 0,
455
+ success_rate: count > 0 ? (successful.to_f / count * 100).round(1) : 0
456
+ }
457
+ end
458
+ end
335
459
  end
336
460
  end
337
461
  end
@@ -14,6 +14,7 @@ module RubyLLM
14
14
  class ExecutionsController < ApplicationController
15
15
  include Paginatable
16
16
  include Filterable
17
+ include Sortable
17
18
 
18
19
  CSV_COLUMNS = %w[id agent_type agent_version status model_id total_tokens total_cost
19
20
  duration_ms created_at error_class error_message].freeze
@@ -206,12 +207,13 @@ module RubyLLM
206
207
 
207
208
  # Loads paginated executions and associated statistics
208
209
  #
209
- # Sets @executions, @pagination, and @filter_stats instance variables
210
+ # Sets @executions, @pagination, @sort_params, and @filter_stats instance variables
210
211
  # for use in views.
211
212
  #
212
213
  # @return [void]
213
214
  def load_executions_with_stats
214
- result = paginate(filtered_executions)
215
+ @sort_params = parse_sort_params
216
+ result = paginate(filtered_executions, sort_params: @sort_params)
215
217
  @executions = result[:records]
216
218
  @pagination = result[:pagination]
217
219
  load_filter_stats
@@ -238,9 +240,6 @@ module RubyLLM
238
240
  def filtered_executions
239
241
  scope = tenant_scoped_executions
240
242
 
241
- # Apply search filter
242
- scope = scope.search(params[:q]) if params[:q].present?
243
-
244
243
  # Apply agent type filter
245
244
  agent_types = parse_array_param(:agent_types)
246
245
  if agent_types.any?
@@ -288,6 +287,9 @@ module RubyLLM
288
287
  # Apply execution type tab filter (agents vs workflows)
289
288
  scope = apply_execution_type_filter(scope)
290
289
 
290
+ # Apply retries filter (show only executions with multiple attempts)
291
+ scope = scope.where("attempts_count > 1") if params[:has_retries].present?
292
+
291
293
  # Only show root executions (not workflow children) - children are nested under parents
292
294
  scope = scope.where(parent_execution_id: nil)
293
295
 
@@ -297,7 +299,7 @@ module RubyLLM
297
299
  scope
298
300
  end
299
301
 
300
- # Applies execution type tab filter (all, agents, workflows)
302
+ # Applies execution type filter (all, agents, workflows, or specific workflow type)
301
303
  #
302
304
  # @param scope [ActiveRecord::Relation] The current scope
303
305
  # @return [ActiveRecord::Relation] Filtered scope
@@ -310,16 +312,11 @@ module RubyLLM
310
312
  # Only show executions where workflow_type is null/empty (regular agents)
311
313
  scope.where(workflow_type: [nil, ""])
312
314
  when "workflows"
313
- # Only show executions with a workflow_type
314
- workflow_scope = scope.where.not(workflow_type: [nil, ""])
315
-
316
- # Apply workflow type sub-filter if specified
317
- workflow_type_tab = params[:workflow_type_tab]
318
- if workflow_type_tab.present? && %w[pipeline parallel router].include?(workflow_type_tab)
319
- workflow_scope = workflow_scope.where(workflow_type: workflow_type_tab)
320
- end
321
-
322
- workflow_scope
315
+ # Only show executions with a workflow_type (any workflow)
316
+ scope.where.not(workflow_type: [nil, ""])
317
+ when "pipeline", "parallel", "router"
318
+ # Show specific workflow type
319
+ scope.where(workflow_type: execution_type)
323
320
  else
324
321
  scope
325
322
  end