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
@@ -16,6 +16,24 @@ module RubyLLM
16
16
  include Paginatable
17
17
  include Filterable
18
18
 
19
+ # Lists all registered workflows with their details
20
+ #
21
+ # Uses AgentRegistry to discover workflows from both file system
22
+ # and execution history. Separates workflows by type for sub-tabs.
23
+ # Supports sorting by various columns.
24
+ #
25
+ # @return [void]
26
+ def index
27
+ all_items = AgentRegistry.all_with_details
28
+ @workflows = all_items.select { |a| a[:is_workflow] }
29
+ @sort_params = parse_sort_params
30
+ @workflows = sort_workflows(@workflows)
31
+ rescue StandardError => e
32
+ Rails.logger.error("[RubyLLM::Agents] Error loading workflows: #{e.message}")
33
+ @workflows = []
34
+ flash.now[:alert] = "Error loading workflows list"
35
+ end
36
+
19
37
  # Shows detailed view for a specific workflow
20
38
  #
21
39
  # Loads workflow configuration (if class exists), statistics,
@@ -42,23 +60,20 @@ module RubyLLM
42
60
  end
43
61
  rescue StandardError => e
44
62
  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"
63
+ redirect_to ruby_llm_agents.workflows_path, alert: "Error loading workflow details"
46
64
  end
47
65
 
48
66
  private
49
67
 
50
- # Detects the workflow type kind (pipeline, parallel, router)
68
+ # Detects the workflow type kind
69
+ #
70
+ # All workflows now use the DSL and return "workflow" type.
51
71
  #
52
72
  # @return [String, nil] The workflow type kind
53
73
  def detect_workflow_type_kind
54
74
  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"
75
+ if @workflow_class.respond_to?(:step_configs) && @workflow_class.step_configs.any?
76
+ "workflow"
62
77
  end
63
78
  else
64
79
  # Fallback to execution history
@@ -157,7 +172,6 @@ module RubyLLM
157
172
  # @return [void]
158
173
  def load_step_stats
159
174
  @step_stats = calculate_step_stats
160
- @route_distribution = calculate_route_distribution if @workflow_type_kind == "router"
161
175
  end
162
176
 
163
177
  # Calculates per-step/branch performance statistics
@@ -215,29 +229,6 @@ module RubyLLM
215
229
  end.compact
216
230
  end
217
231
 
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
232
  # Loads the current workflow class configuration
242
233
  #
243
234
  # @return [void]
@@ -250,91 +241,252 @@ module RubyLLM
250
241
  max_cost: safe_call(@workflow_class, :max_cost)
251
242
  }
252
243
 
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
244
+ # Load unified workflow structure for all types
245
+ load_unified_workflow_config
262
246
  end
263
247
 
264
- # Loads pipeline-specific configuration
248
+ # Loads unified workflow configuration for all workflow types
265
249
  #
266
250
  # @return [void]
267
- def load_pipeline_config
268
- @steps = extract_steps(@workflow_class)
251
+ def load_unified_workflow_config
252
+ @parallel_groups = []
253
+ @input_schema_fields = {}
254
+ @lifecycle_hooks = {}
255
+
256
+ # All workflows use DSL
257
+ @steps = extract_dsl_steps(@workflow_class)
258
+ @parallel_groups = extract_parallel_groups(@workflow_class)
259
+ @lifecycle_hooks = extract_lifecycle_hooks(@workflow_class)
260
+
269
261
  @config[:steps_count] = @steps.size
262
+ @config[:parallel_groups_count] = @parallel_groups.size
263
+ @config[:has_routing] = @steps.any? { |s| s[:routing] }
264
+ @config[:has_conditions] = @steps.any? { |s| s[:if_condition] || s[:unless_condition] }
265
+ @config[:has_retries] = @steps.any? { |s| s[:retry_config] }
266
+ @config[:has_fallbacks] = @steps.any? { |s| s[:fallbacks]&.any? }
267
+ @config[:has_lifecycle_hooks] = @lifecycle_hooks.values.any? { |v| v.to_i > 0 }
268
+ @config[:has_input_schema] = @workflow_class.respond_to?(:input_schema) && @workflow_class.input_schema.present?
269
+
270
+ if @config[:has_input_schema]
271
+ @input_schema_fields = @workflow_class.input_schema.fields.transform_values(&:to_h)
272
+ end
270
273
  end
271
274
 
272
- # Loads parallel-specific configuration
275
+ # Extracts steps from a DSL-based workflow class with full configuration
273
276
  #
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?)
277
+ # @param klass [Class] The workflow class
278
+ # @return [Array<Hash>] Array of step hashes with full DSL metadata
279
+ def extract_dsl_steps(klass)
280
+ return [] unless klass.respond_to?(:step_metadata) && klass.respond_to?(:step_configs)
281
+
282
+ step_configs = klass.step_configs
283
+
284
+ klass.step_metadata.map do |meta|
285
+ # Handle wait steps - they have type: :wait and aren't in step_configs
286
+ if meta[:type] == :wait
287
+ {
288
+ name: meta[:name],
289
+ type: :wait,
290
+ wait_type: meta[:wait_type],
291
+ ui_label: meta[:ui_label],
292
+ timeout: meta[:timeout],
293
+ duration: meta[:duration],
294
+ poll_interval: meta[:poll_interval],
295
+ on_timeout: meta[:on_timeout],
296
+ notify: meta[:notify],
297
+ approvers: meta[:approvers],
298
+ parallel: false
299
+ }.compact
300
+ else
301
+ config = step_configs[meta[:name]]
302
+ step_hash = {
303
+ name: meta[:name],
304
+ agent: meta[:agent],
305
+ description: meta[:description],
306
+ ui_label: meta[:ui_label],
307
+ optional: meta[:optional],
308
+ timeout: meta[:timeout],
309
+ routing: meta[:routing],
310
+ parallel: meta[:parallel],
311
+ parallel_group: meta[:parallel_group],
312
+ custom_block: config&.custom_block?,
313
+ # New composition features
314
+ workflow: meta[:workflow],
315
+ iteration: meta[:iteration],
316
+ iteration_concurrency: meta[:iteration_concurrency]
317
+ }
318
+
319
+ # Add extended configuration from StepConfig
320
+ if config
321
+ step_hash.merge!(
322
+ retry_config: extract_retry_config(config),
323
+ fallbacks: config.fallbacks.map(&:name),
324
+ if_condition: describe_condition(config.if_condition),
325
+ unless_condition: describe_condition(config.unless_condition),
326
+ has_input_mapper: config.input_mapper.present?,
327
+ pick_fields: config.pick_fields,
328
+ pick_from: config.pick_from,
329
+ default_value: config.default_value,
330
+ routes: extract_routes(config),
331
+ # Iteration error handling
332
+ iteration_fail_fast: config.iteration_fail_fast?,
333
+ continue_on_error: config.continue_on_error?
334
+ )
335
+
336
+ # Add sub-workflow metadata for nested workflow steps
337
+ if config.workflow? && config.agent
338
+ step_hash[:sub_workflow] = extract_sub_workflow_metadata(config.agent)
339
+ end
340
+ end
341
+
342
+ step_hash.compact
343
+ end
344
+ end
279
345
  end
280
346
 
281
- # Loads router-specific configuration
347
+ # Extracts retry configuration in a display-friendly format
282
348
  #
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)
349
+ # @param config [StepConfig] The step configuration
350
+ # @return [Hash, nil] Retry config hash or nil
351
+ def extract_retry_config(config)
352
+ retry_cfg = config.retry_config
353
+ return nil unless retry_cfg && retry_cfg[:max].to_i > 0
354
+
355
+ {
356
+ max: retry_cfg[:max],
357
+ backoff: retry_cfg[:backoff],
358
+ delay: retry_cfg[:delay]
359
+ }
289
360
  end
290
361
 
291
- # Extracts steps from a pipeline workflow class
362
+ # Describes a condition for display
292
363
  #
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)
364
+ # @param condition [Symbol, Proc, nil] The condition
365
+ # @return [String, nil] Human-readable description
366
+ def describe_condition(condition)
367
+ return nil if condition.nil?
368
+
369
+ case condition
370
+ when Symbol then condition.to_s
371
+ when Proc then "lambda"
372
+ else condition.to_s
373
+ end
374
+ end
375
+
376
+ # Extracts routes from a routing step
377
+ #
378
+ # @param config [StepConfig] The step configuration
379
+ # @return [Array<Hash>, nil] Array of route hashes or nil
380
+ def extract_routes(config)
381
+ return nil unless config.routing? && config.block
382
+
383
+ builder = RubyLLM::Agents::Workflow::DSL::RouteBuilder.new
384
+ config.block.call(builder)
297
385
 
298
- klass.steps.map do |name, config|
386
+ routes = builder.routes.map do |name, route_config|
299
387
  {
300
- name: name,
301
- agent: config[:agent]&.name,
302
- optional: config[:continue_on_error] || false
303
- }
388
+ name: name.to_s,
389
+ agent: route_config[:agent]&.name,
390
+ timeout: extract_timeout_value(route_config[:options][:timeout]),
391
+ fallback: Array(route_config[:options][:fallback]).first&.then { |f| f.respond_to?(:name) ? f.name : f.to_s },
392
+ has_input_mapper: route_config[:options][:input].present?,
393
+ if_condition: describe_condition(route_config[:options][:if]),
394
+ default: false
395
+ }.compact
396
+ end
397
+
398
+ # Add default route
399
+ if builder.default
400
+ routes << {
401
+ name: "default",
402
+ agent: builder.default[:agent]&.name,
403
+ timeout: extract_timeout_value(builder.default[:options][:timeout]),
404
+ has_input_mapper: builder.default[:options][:input].present?,
405
+ default: true
406
+ }.compact
304
407
  end
408
+
409
+ routes
410
+ rescue StandardError => e
411
+ Rails.logger.debug "[RubyLLM::Agents] Could not extract routes: #{e.message}"
412
+ nil
305
413
  end
306
414
 
307
- # Extracts branches from a parallel workflow class
415
+ # Extracts timeout value handling ActiveSupport::Duration
308
416
  #
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)
417
+ # @param timeout [Integer, ActiveSupport::Duration, nil] The timeout value
418
+ # @return [Integer, nil] Timeout in seconds or nil
419
+ def extract_timeout_value(timeout)
420
+ return nil if timeout.nil?
421
+
422
+ timeout.respond_to?(:to_i) ? timeout.to_i : timeout
423
+ end
424
+
425
+ # Extracts metadata for a nested sub-workflow
426
+ #
427
+ # @param workflow_class [Class] The sub-workflow class
428
+ # @return [Hash] Sub-workflow metadata including steps preview and budget info
429
+ def extract_sub_workflow_metadata(workflow_class)
430
+ return nil unless workflow_class.respond_to?(:step_metadata)
431
+
432
+ {
433
+ name: workflow_class.name,
434
+ description: safe_call(workflow_class, :description),
435
+ timeout: safe_call(workflow_class, :timeout),
436
+ max_cost: safe_call(workflow_class, :max_cost),
437
+ max_recursion_depth: safe_call(workflow_class, :max_recursion_depth),
438
+ steps_count: workflow_class.step_configs.size,
439
+ steps_preview: extract_sub_workflow_steps_preview(workflow_class)
440
+ }.compact
441
+ rescue StandardError => e
442
+ Rails.logger.debug "[RubyLLM::Agents] Could not extract sub-workflow metadata: #{e.message}"
443
+ nil
444
+ end
445
+
446
+ # Extracts a simplified steps preview for sub-workflow display
447
+ #
448
+ # @param workflow_class [Class] The sub-workflow class
449
+ # @return [Array<Hash>] Simplified step hashes for preview
450
+ def extract_sub_workflow_steps_preview(workflow_class)
451
+ return [] unless workflow_class.respond_to?(:step_metadata)
313
452
 
314
- klass.branches.map do |name, config|
453
+ workflow_class.step_metadata.map do |meta|
315
454
  {
316
- name: name,
317
- agent: config[:agent]&.name,
318
- optional: config[:optional] || false
319
- }
455
+ name: meta[:name],
456
+ agent: meta[:agent]&.gsub(/Agent$/, "")&.gsub(/Workflow$/, ""),
457
+ routing: meta[:routing],
458
+ iteration: meta[:iteration],
459
+ workflow: meta[:workflow],
460
+ parallel: meta[:parallel]
461
+ }.compact
320
462
  end
463
+ rescue StandardError
464
+ []
321
465
  end
322
466
 
323
- # Extracts routes from a router workflow class
467
+ # Extracts parallel groups from a DSL-based workflow class
324
468
  #
325
469
  # @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)
470
+ # @return [Array<Hash>] Array of parallel group hashes
471
+ def extract_parallel_groups(klass)
472
+ return [] unless klass.respond_to?(:parallel_groups)
329
473
 
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
474
+ klass.parallel_groups.map(&:to_h)
475
+ end
476
+
477
+ # Extracts lifecycle hooks from a workflow class
478
+ #
479
+ # @param klass [Class] The workflow class
480
+ # @return [Hash] Hash of hook types to counts
481
+ def extract_lifecycle_hooks(klass)
482
+ return {} unless klass.respond_to?(:lifecycle_hooks)
483
+
484
+ hooks = klass.lifecycle_hooks
485
+ {
486
+ before_workflow: hooks[:before_workflow]&.size || 0,
487
+ after_workflow: hooks[:after_workflow]&.size || 0,
488
+ on_step_error: hooks[:on_step_error]&.size || 0
489
+ }
338
490
  end
339
491
 
340
492
  # Safely calls a method on a class, returning nil if method doesn't exist
@@ -350,6 +502,43 @@ module RubyLLM
350
502
  rescue StandardError
351
503
  nil
352
504
  end
505
+
506
+ # Parses and validates sort parameters from request
507
+ #
508
+ # @return [Hash] Hash with :column and :direction keys
509
+ def parse_sort_params
510
+ allowed_columns = %w[name workflow_type execution_count total_cost success_rate last_executed]
511
+ column = params[:sort]
512
+ direction = params[:direction]
513
+
514
+ {
515
+ column: allowed_columns.include?(column) ? column : "name",
516
+ direction: %w[asc desc].include?(direction) ? direction : "asc"
517
+ }
518
+ end
519
+
520
+ # Sorts workflows array by the specified column and direction
521
+ #
522
+ # @param workflows [Array<Hash>] Array of workflow hashes
523
+ # @return [Array<Hash>] Sorted array
524
+ def sort_workflows(workflows)
525
+ column = @sort_params[:column].to_sym
526
+ direction = @sort_params[:direction]
527
+
528
+ sorted = workflows.sort_by do |w|
529
+ value = w[column]
530
+ case column
531
+ when :name
532
+ value.to_s.downcase
533
+ when :last_executed
534
+ value || Time.at(0)
535
+ else
536
+ value || 0
537
+ end
538
+ end
539
+
540
+ direction == "desc" ? sorted.reverse : sorted
541
+ end
353
542
  end
354
543
  end
355
544
  end
@@ -11,6 +11,39 @@ module RubyLLM
11
11
  module ApplicationHelper
12
12
  include Chartkick::Helper if defined?(Chartkick)
13
13
 
14
+ # Wiki base URL for documentation links
15
+ WIKI_BASE_URL = "https://github.com/adham90/ruby_llm-agents/wiki/".freeze
16
+
17
+ # Page to documentation mapping
18
+ DOC_PAGES = {
19
+ "dashboard/index" => "Dashboard",
20
+ "agents/index" => "Agent-DSL",
21
+ "agents/show" => "Agent-DSL",
22
+ "workflows/index" => "Workflows",
23
+ "workflows/show" => "Workflows",
24
+ "executions/index" => "Execution-Tracking",
25
+ "executions/show" => "Execution-Tracking",
26
+ "tenants/index" => "Multi-Tenancy",
27
+ "system_config/show" => "Configuration",
28
+ "api_configurations/show" => "Configuration"
29
+ }.freeze
30
+
31
+ # Returns the documentation URL for the current page or a specific page key
32
+ #
33
+ # @param page_key [String, nil] Optional page key (e.g., "agents/index")
34
+ # @return [String, nil] The documentation URL or nil if no mapping exists
35
+ # @example Get documentation URL for current page
36
+ # documentation_url #=> "https://github.com/adham90/ruby_llm-agents/wiki/Agent-DSL"
37
+ # @example Get documentation URL for specific page
38
+ # documentation_url("dashboard/index") #=> "https://github.com/adham90/ruby_llm-agents/wiki/Dashboard"
39
+ def documentation_url(page_key = nil)
40
+ key = page_key || "#{controller_name}/#{action_name}"
41
+ doc_page = DOC_PAGES[key]
42
+ return nil unless doc_page
43
+
44
+ "#{WIKI_BASE_URL}#{doc_page}"
45
+ end
46
+
14
47
  # Returns the URL helpers for the engine's routes
15
48
  #
16
49
  # Use this to generate paths and URLs within the dashboard views.
@@ -250,6 +283,73 @@ module RubyLLM
250
283
  end
251
284
  end
252
285
 
286
+ # Compact comparison indicator with arrow for now strip metrics
287
+ #
288
+ # Shows a colored arrow indicator showing percentage change vs previous period.
289
+ # For errors/cost/duration: decrease is good (green). For success/tokens: increase is good.
290
+ #
291
+ # @param change_pct [Float, nil] Percentage change from previous period
292
+ # @param metric_type [Symbol] Type of metric (:success, :errors, :cost, :duration, :tokens)
293
+ # @return [ActiveSupport::SafeBuffer, String] HTML span with indicator or empty string
294
+ def comparison_indicator(change_pct, metric_type: :count)
295
+ return "".html_safe if change_pct.nil?
296
+
297
+ # For errors/cost/duration, decrease is good. For success/tokens, increase is good.
298
+ positive_is_good = metric_type.in?(%i[success tokens count])
299
+ is_improvement = positive_is_good ? change_pct > 0 : change_pct < 0
300
+
301
+ arrow = change_pct > 0 ? "\u2191" : "\u2193"
302
+ color = is_improvement ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"
303
+
304
+ content_tag(:span, "#{arrow}#{change_pct.abs}%", class: "text-xs font-medium #{color} ml-1")
305
+ end
306
+
307
+ # Returns human-readable display name for time range
308
+ #
309
+ # @param range [String] Range parameter (today, 7d, 30d, 60d, 90d, or custom YYYY-MM-DD_YYYY-MM-DD)
310
+ # @return [String] Human-readable range name
311
+ # @example
312
+ # range_display_name("7d") #=> "Last 7 Days"
313
+ # range_display_name("2024-01-01_2024-01-15") #=> "Jan 1 - Jan 15"
314
+ def range_display_name(range)
315
+ case range
316
+ when "today" then "Today"
317
+ when "7d" then "Last 7 Days"
318
+ when "30d" then "Last 30 Days"
319
+ when "60d" then "Last 60 Days"
320
+ when "90d" then "Last 90 Days"
321
+ else
322
+ if range&.include?("_")
323
+ from_str, to_str = range.split("_")
324
+ from_date = Date.parse(from_str) rescue nil
325
+ to_date = Date.parse(to_str) rescue nil
326
+ if from_date && to_date
327
+ "#{from_date.strftime('%b %-d')} - #{to_date.strftime('%b %-d')}"
328
+ else
329
+ "Custom Range"
330
+ end
331
+ else
332
+ "Today"
333
+ end
334
+ end
335
+ end
336
+
337
+ # Formats milliseconds to human-readable duration
338
+ #
339
+ # @param ms [Numeric, nil] Duration in milliseconds
340
+ # @return [String] Human-readable duration (e.g., "150ms", "2.5s", "1.2m")
341
+ def format_duration_ms(ms)
342
+ return "0ms" if ms.nil? || ms.zero?
343
+
344
+ if ms < 1000
345
+ "#{ms.round}ms"
346
+ elsif ms < 60_000
347
+ "#{(ms / 1000.0).round(1)}s"
348
+ else
349
+ "#{(ms / 60_000.0).round(1)}m"
350
+ end
351
+ end
352
+
253
353
  # Returns the appropriate row background class based on change significance
254
354
  #
255
355
  # @param change_pct [Float] Percentage change
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Mailer for sending alert notifications via email
6
+ #
7
+ # Delivers alert notifications when important events occur like
8
+ # budget exceedance or circuit breaker activation.
9
+ #
10
+ # @example Sending an alert email
11
+ # AlertMailer.alert_notification(
12
+ # event: :budget_hard_cap,
13
+ # payload: { limit: 100.0, total: 105.0 },
14
+ # recipient: "admin@example.com"
15
+ # ).deliver_later
16
+ #
17
+ # @api public
18
+ class AlertMailer < ApplicationMailer
19
+ # Sends an alert notification email
20
+ #
21
+ # @param event [Symbol] The event type (e.g., :budget_soft_cap, :breaker_open)
22
+ # @param payload [Hash] Event-specific data
23
+ # @param recipient [String] Email address of the recipient
24
+ # @return [Mail::Message]
25
+ def alert_notification(event:, payload:, recipient:)
26
+ @event = event
27
+ @payload = payload
28
+ @title = event_title(event)
29
+ @severity = event_severity(event)
30
+ @color = event_color(event)
31
+ @timestamp = Time.current
32
+
33
+ mail(
34
+ to: recipient,
35
+ subject: "[RubyLLM::Agents Alert] #{@title}"
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ # Returns human-readable title for event type
42
+ #
43
+ # @param event [Symbol] The event type
44
+ # @return [String] Human-readable title
45
+ def event_title(event)
46
+ case event
47
+ when :budget_soft_cap then "Budget Soft Cap Reached"
48
+ when :budget_hard_cap then "Budget Hard Cap Exceeded"
49
+ when :breaker_open then "Circuit Breaker Opened"
50
+ when :agent_anomaly then "Agent Anomaly Detected"
51
+ else event.to_s.titleize
52
+ end
53
+ end
54
+
55
+ # Returns severity level for event type
56
+ #
57
+ # @param event [Symbol] The event type
58
+ # @return [String] Severity level
59
+ def event_severity(event)
60
+ case event
61
+ when :budget_soft_cap then "Warning"
62
+ when :budget_hard_cap then "Critical"
63
+ when :breaker_open then "Critical"
64
+ when :agent_anomaly then "Warning"
65
+ else "Info"
66
+ end
67
+ end
68
+
69
+ # Returns color for event type
70
+ #
71
+ # @param event [Symbol] The event type
72
+ # @return [String] Hex color code
73
+ def event_color(event)
74
+ case event
75
+ when :budget_soft_cap then "#FFA500" # Orange
76
+ when :budget_hard_cap then "#FF0000" # Red
77
+ when :breaker_open then "#FF0000" # Red
78
+ when :agent_anomaly then "#FFA500" # Orange
79
+ else "#0000FF" # Blue
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Base mailer class for RubyLLM::Agents
6
+ #
7
+ # Host application must configure ActionMailer with SMTP settings
8
+ # for email delivery to work.
9
+ #
10
+ # @api private
11
+ class ApplicationMailer < ::ActionMailer::Base
12
+ default from: -> { default_from_address }
13
+
14
+ layout false # Templates are self-contained
15
+
16
+ private
17
+
18
+ def default_from_address
19
+ RubyLLM::Agents.configuration.alerts&.dig(:email_from) ||
20
+ "noreply@#{default_host}"
21
+ end
22
+
23
+ def default_host
24
+ ::ActionMailer::Base.default_url_options[:host] || "example.com"
25
+ end
26
+ end
27
+ end
28
+ end