ruby_llm-agents 1.0.0 → 1.1.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 (139) 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/services/ruby_llm/agents/agent_registry.rb +18 -12
  16. data/app/views/layouts/ruby_llm/agents/application.html.erb +72 -76
  17. data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -12
  18. data/app/views/ruby_llm/agents/agents/_sortable_header.html.erb +56 -0
  19. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +5 -15
  20. data/app/views/ruby_llm/agents/agents/index.html.erb +271 -100
  21. data/app/views/ruby_llm/agents/agents/show.html.erb +1 -0
  22. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +107 -0
  23. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +18 -0
  24. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +4 -1
  25. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +66 -359
  26. data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +56 -0
  27. data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +115 -0
  28. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +35 -60
  29. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +17 -6
  30. data/app/views/ruby_llm/agents/dashboard/index.html.erb +373 -72
  31. data/app/views/ruby_llm/agents/executions/_execution.html.erb +0 -1
  32. data/app/views/ruby_llm/agents/executions/_filters.html.erb +51 -39
  33. data/app/views/ruby_llm/agents/executions/_list.html.erb +53 -195
  34. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +5 -20
  35. data/app/views/ruby_llm/agents/executions/index.html.erb +7 -83
  36. data/app/views/ruby_llm/agents/executions/show.html.erb +10 -20
  37. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +2 -1
  38. data/app/views/ruby_llm/agents/shared/_doc_link.html.erb +12 -0
  39. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +3 -15
  40. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +1 -1
  41. data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +1 -1
  42. data/app/views/ruby_llm/agents/shared/_sortable_header.html.erb +53 -0
  43. data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +7 -0
  44. data/app/views/ruby_llm/agents/shared/_status_dot.html.erb +1 -1
  45. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +9 -35
  46. data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -1
  47. data/app/views/ruby_llm/agents/tenants/index.html.erb +4 -1
  48. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +7 -15
  49. data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +539 -0
  50. data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +920 -0
  51. data/app/views/ruby_llm/agents/workflows/index.html.erb +179 -0
  52. data/app/views/ruby_llm/agents/workflows/show.html.erb +164 -139
  53. data/config/routes.rb +1 -1
  54. data/lib/generators/ruby_llm_agents/agent_generator.rb +6 -36
  55. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +7 -37
  56. data/lib/generators/ruby_llm_agents/embedder_generator.rb +5 -38
  57. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -37
  58. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +7 -37
  59. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +8 -41
  60. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +18 -46
  61. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +7 -37
  62. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +7 -37
  63. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +7 -37
  64. data/lib/generators/ruby_llm_agents/install_generator.rb +33 -56
  65. data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +480 -0
  66. data/lib/generators/ruby_llm_agents/restructure_generator.rb +2 -2
  67. data/lib/generators/ruby_llm_agents/speaker_generator.rb +8 -39
  68. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +5 -8
  69. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +40 -42
  70. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +20 -22
  71. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +24 -26
  72. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +20 -22
  73. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +19 -17
  74. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +31 -33
  75. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +125 -127
  76. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +20 -18
  77. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +19 -17
  78. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +19 -17
  79. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +38 -40
  80. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +42 -44
  81. data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +48 -0
  82. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +19 -21
  83. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +19 -21
  84. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +20 -22
  85. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +15 -17
  86. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +25 -27
  87. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +19 -21
  88. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +20 -22
  89. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +17 -19
  90. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +15 -17
  91. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +87 -24
  92. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +21 -27
  93. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +46 -54
  94. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +31 -39
  95. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +22 -28
  96. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +53 -63
  97. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +46 -56
  98. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +23 -31
  99. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +22 -30
  100. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +23 -31
  101. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +38 -46
  102. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +7 -7
  103. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +59 -71
  104. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +274 -23
  105. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +29 -31
  106. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +28 -30
  107. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +10 -43
  108. data/lib/ruby_llm/agents/core/configuration.rb +55 -43
  109. data/lib/ruby_llm/agents/core/version.rb +1 -1
  110. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +26 -0
  111. data/lib/ruby_llm/agents/pipeline.rb +69 -0
  112. data/lib/ruby_llm/agents/workflow/approval.rb +205 -0
  113. data/lib/ruby_llm/agents/workflow/approval_store.rb +179 -0
  114. data/lib/ruby_llm/agents/workflow/dsl/executor.rb +467 -0
  115. data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +244 -0
  116. data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +289 -0
  117. data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +107 -0
  118. data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +150 -0
  119. data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +187 -0
  120. data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +352 -0
  121. data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +415 -0
  122. data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +257 -0
  123. data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +317 -0
  124. data/lib/ruby_llm/agents/workflow/dsl.rb +576 -0
  125. data/lib/ruby_llm/agents/workflow/instrumentation.rb +2 -7
  126. data/lib/ruby_llm/agents/workflow/notifiers/base.rb +117 -0
  127. data/lib/ruby_llm/agents/workflow/notifiers/email.rb +117 -0
  128. data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +180 -0
  129. data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +121 -0
  130. data/lib/ruby_llm/agents/workflow/notifiers.rb +70 -0
  131. data/lib/ruby_llm/agents/workflow/orchestrator.rb +190 -23
  132. data/lib/ruby_llm/agents/workflow/result.rb +202 -0
  133. data/lib/ruby_llm/agents/workflow/throttle_manager.rb +206 -0
  134. data/lib/ruby_llm/agents/workflow/wait_result.rb +213 -0
  135. metadata +37 -6
  136. data/app/views/ruby_llm/agents/dashboard/_execution_item.html.erb +0 -66
  137. data/lib/ruby_llm/agents/workflow/parallel.rb +0 -299
  138. data/lib/ruby_llm/agents/workflow/pipeline.rb +0 -306
  139. data/lib/ruby_llm/agents/workflow/router.rb +0 -429
@@ -702,8 +702,8 @@ module RubyLLM
702
702
  @default_background_output_format = :png # Output format for transparency
703
703
 
704
704
  # Directory structure defaults
705
- @root_directory = "llm" # Root directory under app/
706
- @root_namespace = "Llm" # Root namespace for classes
705
+ @root_directory = "agents" # Root directory under app/
706
+ @root_namespace = nil # No namespace (top-level classes)
707
707
  end
708
708
 
709
709
  # Returns the configured cache store, falling back to Rails.cache
@@ -744,7 +744,8 @@ module RubyLLM
744
744
 
745
745
  alerts[:slack_webhook_url].present? ||
746
746
  alerts[:webhook_url].present? ||
747
- alerts[:custom].present?
747
+ alerts[:custom].present? ||
748
+ alerts[:email_recipients].present?
748
749
  end
749
750
 
750
751
  # Returns the list of events to alert on
@@ -822,28 +823,27 @@ module RubyLLM
822
823
  #
823
824
  # @param category [Symbol, nil] Category (:audio, :image, :text, or nil for root)
824
825
  # @return [String, nil] Full namespace string, or nil if no namespace configured
825
- # @example With default namespace
826
+ # @example With root_namespace = "Llm"
826
827
  # namespace_for(:image) #=> "Llm::Image"
827
828
  # namespace_for(nil) #=> "Llm"
828
829
  # @example With no namespace (root_namespace = nil)
829
830
  # namespace_for(:image) #=> "Image"
830
831
  # namespace_for(nil) #=> nil
831
832
  def namespace_for(category = nil)
832
- # Handle no namespace configuration
833
- if root_namespace.blank?
834
- case category
835
- when :audio then "Audio"
836
- when :image then "Image"
837
- when :text then "Text"
838
- else nil
839
- end
833
+ category_namespace = case category
834
+ when :images then "Images"
835
+ when :audio then "Audio"
836
+ when :embedders then "Embedders"
837
+ when :moderators then "Moderators"
838
+ when :workflows then "Workflows"
839
+ when :text then "Text"
840
+ when :image then "Image"
841
+ end
842
+
843
+ if root_namespace
844
+ category_namespace ? "#{root_namespace}::#{category_namespace}" : root_namespace
840
845
  else
841
- case category
842
- when :audio then "#{root_namespace}::Audio"
843
- when :image then "#{root_namespace}::Image"
844
- when :text then "#{root_namespace}::Text"
845
- else root_namespace
846
- end
846
+ category_namespace
847
847
  end
848
848
  end
849
849
 
@@ -852,15 +852,34 @@ module RubyLLM
852
852
  # @param category [Symbol, nil] Category (:audio, :image, :text, or nil)
853
853
  # @param type [String] Component type (e.g., "agents", "speakers", "generators")
854
854
  # @return [String] Full path relative to Rails.root
855
- # @example
855
+ # @example With root_directory = "llm"
856
856
  # path_for(:image, "generators") #=> "app/llm/image/generators"
857
857
  # path_for(nil, "agents") #=> "app/llm/agents"
858
- def path_for(category, type)
859
- case category
860
- when :audio then "app/#{root_directory}/audio/#{type}"
861
- when :image then "app/#{root_directory}/image/#{type}"
862
- when :text then "app/#{root_directory}/text/#{type}"
863
- else "app/#{root_directory}/#{type}"
858
+ # @example With root_directory = "agents" (default)
859
+ # path_for(:images) #=> "app/agents/images"
860
+ # path_for(nil, "tools") #=> "app/agents/tools"
861
+ def path_for(category, type = nil)
862
+ root = root_directory || "agents"
863
+ base = "app/#{root}"
864
+
865
+ category_path = case category
866
+ when :images then "images"
867
+ when :audio then "audio"
868
+ when :embedders then "embedders"
869
+ when :moderators then "moderators"
870
+ when :workflows then "workflows"
871
+ when :text then "text"
872
+ when :image then "image"
873
+ end
874
+
875
+ if category_path && type
876
+ "#{base}/#{category_path}/#{type}"
877
+ elsif category_path
878
+ "#{base}/#{category_path}"
879
+ elsif type
880
+ "#{base}/#{type}"
881
+ else
882
+ base
864
883
  end
865
884
  end
866
885
 
@@ -868,25 +887,18 @@ module RubyLLM
868
887
  #
869
888
  # @return [Array<String>] List of paths relative to Rails.root
870
889
  def all_autoload_paths
890
+ root = root_directory || "agents"
891
+ base = "app/#{root}"
892
+
871
893
  [
872
- # Top-level
873
- path_for(nil, "agents"),
874
- path_for(nil, "workflows"),
875
- path_for(nil, "tools"),
876
- # Audio
877
- path_for(:audio, "speakers"),
878
- path_for(:audio, "transcribers"),
879
- # Image
880
- path_for(:image, "analyzers"),
881
- path_for(:image, "background_removers"),
882
- path_for(:image, "editors"),
883
- path_for(:image, "generators"),
884
- path_for(:image, "transformers"),
885
- path_for(:image, "upscalers"),
886
- path_for(:image, "variators"),
887
- # Text
888
- path_for(:text, "embedders"),
889
- path_for(:text, "moderators")
894
+ base,
895
+ "app/workflows", # Top-level workflows directory
896
+ "#{base}/images",
897
+ "#{base}/audio",
898
+ "#{base}/embedders",
899
+ "#{base}/moderators",
900
+ "#{base}/workflows",
901
+ "#{base}/tools"
890
902
  ]
891
903
  end
892
904
 
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "1.0.0"
7
+ VERSION = "1.1.0"
8
8
  end
9
9
  end
@@ -46,6 +46,14 @@ module RubyLLM
46
46
  call_custom_alert(alerts[:custom], event, full_payload)
47
47
  end
48
48
 
49
+ # Send email alerts
50
+ if alerts[:email_recipients].present?
51
+ email_events = alerts[:email_events] || config.alert_events
52
+ if email_events.include?(event)
53
+ send_email_alerts(event, full_payload, alerts[:email_recipients])
54
+ end
55
+ end
56
+
49
57
  # Emit ActiveSupport::Notification for observability
50
58
  emit_notification(event, full_payload)
51
59
  rescue StandardError => e
@@ -92,6 +100,24 @@ module RubyLLM
92
100
  Rails.logger.warn("[RubyLLM::Agents::AlertManager] Custom alert failed: #{e.message}")
93
101
  end
94
102
 
103
+ # Sends email alerts to configured recipients
104
+ #
105
+ # @param event [Symbol] The event type
106
+ # @param payload [Hash] The payload
107
+ # @param recipients [Array<String>] Email addresses
108
+ # @return [void]
109
+ def send_email_alerts(event, payload, recipients)
110
+ Array(recipients).each do |recipient|
111
+ AlertMailer.alert_notification(
112
+ event: event,
113
+ payload: payload,
114
+ recipient: recipient
115
+ ).deliver_later
116
+ end
117
+ rescue StandardError => e
118
+ Rails.logger.warn("[RubyLLM::Agents::AlertManager] Email alert failed: #{e.message}")
119
+ end
120
+
95
121
  # Emits an ActiveSupport::Notification
96
122
  #
97
123
  # @param event [Symbol] The event type
@@ -40,6 +40,75 @@ require_relative "pipeline/middleware/reliability"
40
40
  module RubyLLM
41
41
  module Agents
42
42
  module Pipeline
43
+ # Represents an error result from a failed step
44
+ #
45
+ # Used to track errors that occurred during step execution while
46
+ # allowing the workflow to continue (for optional steps).
47
+ #
48
+ # @api public
49
+ class ErrorResult
50
+ attr_reader :step_name, :error_class, :error_message
51
+
52
+ def initialize(step_name:, error_class:, error_message:)
53
+ @step_name = step_name
54
+ @error_class = error_class
55
+ @error_message = error_message
56
+ end
57
+
58
+ def content
59
+ nil
60
+ end
61
+
62
+ def success?
63
+ false
64
+ end
65
+
66
+ def error?
67
+ true
68
+ end
69
+
70
+ def skipped?
71
+ false
72
+ end
73
+
74
+ def input_tokens
75
+ 0
76
+ end
77
+
78
+ def output_tokens
79
+ 0
80
+ end
81
+
82
+ def total_tokens
83
+ 0
84
+ end
85
+
86
+ def cached_tokens
87
+ 0
88
+ end
89
+
90
+ def input_cost
91
+ 0.0
92
+ end
93
+
94
+ def output_cost
95
+ 0.0
96
+ end
97
+
98
+ def total_cost
99
+ 0.0
100
+ end
101
+
102
+ def to_h
103
+ {
104
+ error: true,
105
+ step_name: step_name,
106
+ error_class: error_class,
107
+ error_message: error_message
108
+ }
109
+ end
110
+ end
111
+
43
112
  class << self
44
113
  # Build a pipeline for an agent class with default middleware
45
114
  #
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RubyLLM
6
+ module Agents
7
+ class Workflow
8
+ # Represents an approval request for human-in-the-loop workflows
9
+ #
10
+ # Tracks the state of an approval including who created it, who can approve it,
11
+ # and the final decision with timestamp and reason.
12
+ #
13
+ # @example Creating an approval
14
+ # approval = Approval.new(
15
+ # workflow_id: "order-123",
16
+ # workflow_type: "OrderApprovalWorkflow",
17
+ # name: :manager_approval,
18
+ # metadata: { order_total: 5000 }
19
+ # )
20
+ #
21
+ # @example Approving
22
+ # approval.approve!("manager@example.com")
23
+ #
24
+ # @example Rejecting
25
+ # approval.reject!("manager@example.com", reason: "Budget exceeded")
26
+ #
27
+ # @api public
28
+ class Approval
29
+ STATUSES = %i[pending approved rejected expired].freeze
30
+
31
+ attr_reader :id, :workflow_id, :workflow_type, :name, :status,
32
+ :created_at, :metadata, :approvers, :expires_at
33
+ attr_accessor :approved_by, :approved_at, :rejected_by, :rejected_at,
34
+ :reason, :reminded_at
35
+
36
+ # @param workflow_id [String] The workflow instance ID
37
+ # @param workflow_type [String] The workflow class name
38
+ # @param name [Symbol] The approval point name
39
+ # @param approvers [Array<String>] List of user IDs who can approve
40
+ # @param expires_at [Time, nil] When the approval expires
41
+ # @param metadata [Hash] Additional context for the approval
42
+ def initialize(workflow_id:, workflow_type:, name:, approvers: [], expires_at: nil, metadata: {})
43
+ @id = SecureRandom.uuid
44
+ @workflow_id = workflow_id
45
+ @workflow_type = workflow_type
46
+ @name = name
47
+ @status = :pending
48
+ @approvers = approvers
49
+ @expires_at = expires_at
50
+ @metadata = metadata
51
+ @created_at = Time.now
52
+ end
53
+
54
+ # Approve the request
55
+ #
56
+ # @param user_id [String] The user approving
57
+ # @param comment [String, nil] Optional comment
58
+ # @return [void]
59
+ def approve!(user_id, comment: nil)
60
+ raise InvalidStateError, "Cannot approve: status is #{status}" unless pending?
61
+
62
+ @status = :approved
63
+ @approved_by = user_id
64
+ @approved_at = Time.now
65
+ @metadata[:approval_comment] = comment if comment
66
+ end
67
+
68
+ # Reject the request
69
+ #
70
+ # @param user_id [String] The user rejecting
71
+ # @param reason [String, nil] Reason for rejection
72
+ # @return [void]
73
+ def reject!(user_id, reason: nil)
74
+ raise InvalidStateError, "Cannot reject: status is #{status}" unless pending?
75
+
76
+ @status = :rejected
77
+ @rejected_by = user_id
78
+ @rejected_at = Time.now
79
+ @reason = reason
80
+ end
81
+
82
+ # Expire the approval request
83
+ #
84
+ # @return [void]
85
+ def expire!
86
+ raise InvalidStateError, "Cannot expire: status is #{status}" unless pending?
87
+
88
+ @status = :expired
89
+ end
90
+
91
+ # Check if approval is still pending
92
+ #
93
+ # @return [Boolean]
94
+ def pending?
95
+ status == :pending
96
+ end
97
+
98
+ # Check if approval was granted
99
+ #
100
+ # @return [Boolean]
101
+ def approved?
102
+ status == :approved
103
+ end
104
+
105
+ # Check if approval was rejected
106
+ #
107
+ # @return [Boolean]
108
+ def rejected?
109
+ status == :rejected
110
+ end
111
+
112
+ # Check if approval has expired
113
+ #
114
+ # @return [Boolean]
115
+ def expired?
116
+ status == :expired
117
+ end
118
+
119
+ # Check if the approval has timed out
120
+ #
121
+ # @return [Boolean]
122
+ def timed_out?
123
+ return false unless expires_at
124
+
125
+ Time.now > expires_at && pending?
126
+ end
127
+
128
+ # Check if a user can approve this request
129
+ #
130
+ # @param user_id [String] The user to check
131
+ # @return [Boolean]
132
+ def can_approve?(user_id)
133
+ return true if approvers.empty? # Anyone can approve if no restrictions
134
+
135
+ approvers.include?(user_id)
136
+ end
137
+
138
+ # Duration since creation
139
+ #
140
+ # @return [Float] Seconds since creation
141
+ def age
142
+ Time.now - created_at
143
+ end
144
+
145
+ # Duration until expiry
146
+ #
147
+ # @return [Float, nil] Seconds until expiry, nil if no expiry
148
+ def time_until_expiry
149
+ return nil unless expires_at
150
+
151
+ expires_at - Time.now
152
+ end
153
+
154
+ # Mark that a reminder was sent
155
+ #
156
+ # @return [void]
157
+ def mark_reminded!
158
+ @reminded_at = Time.now
159
+ end
160
+
161
+ # Check if a reminder should be sent
162
+ #
163
+ # @param reminder_after [Integer] Seconds after creation to send reminder
164
+ # @param reminder_interval [Integer, nil] Interval between reminders
165
+ # @return [Boolean]
166
+ def should_remind?(reminder_after, reminder_interval: nil)
167
+ return false unless pending?
168
+ return false if age < reminder_after
169
+
170
+ if reminded_at && reminder_interval
171
+ Time.now - reminded_at >= reminder_interval
172
+ else
173
+ reminded_at.nil?
174
+ end
175
+ end
176
+
177
+ # Convert to hash for serialization
178
+ #
179
+ # @return [Hash]
180
+ def to_h
181
+ {
182
+ id: id,
183
+ workflow_id: workflow_id,
184
+ workflow_type: workflow_type,
185
+ name: name,
186
+ status: status,
187
+ approvers: approvers,
188
+ approved_by: approved_by,
189
+ approved_at: approved_at,
190
+ rejected_by: rejected_by,
191
+ rejected_at: rejected_at,
192
+ reason: reason,
193
+ expires_at: expires_at,
194
+ reminded_at: reminded_at,
195
+ metadata: metadata,
196
+ created_at: created_at
197
+ }.compact
198
+ end
199
+
200
+ # Error for invalid state transitions
201
+ class InvalidStateError < StandardError; end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ # Abstract base class for approval storage
7
+ #
8
+ # Provides a common interface for storing and retrieving approval requests.
9
+ # Implementations can use in-memory storage, databases, Redis, etc.
10
+ #
11
+ # @example Setting a custom store
12
+ # RubyLLM::Agents::Workflow::ApprovalStore.store = MyRedisStore.new
13
+ #
14
+ # @example Using the default store
15
+ # store = RubyLLM::Agents::Workflow::ApprovalStore.store
16
+ # store.save(approval)
17
+ #
18
+ # @api public
19
+ class ApprovalStore
20
+ class << self
21
+ # Returns the configured store instance
22
+ #
23
+ # @return [ApprovalStore]
24
+ def store
25
+ @store ||= default_store
26
+ end
27
+
28
+ # Sets the store instance
29
+ #
30
+ # @param store [ApprovalStore] The store to use
31
+ # @return [void]
32
+ def store=(store)
33
+ @store = store
34
+ end
35
+
36
+ # Resets to the default store (useful for testing)
37
+ #
38
+ # @return [void]
39
+ def reset!
40
+ @store = nil
41
+ end
42
+
43
+ private
44
+
45
+ def default_store
46
+ MemoryApprovalStore.new
47
+ end
48
+ end
49
+
50
+ # Save an approval
51
+ #
52
+ # @param approval [Approval] The approval to save
53
+ # @return [Approval] The saved approval
54
+ def save(approval)
55
+ raise NotImplementedError, "#{self.class}#save must be implemented"
56
+ end
57
+
58
+ # Find an approval by ID
59
+ #
60
+ # @param id [String] The approval ID
61
+ # @return [Approval, nil]
62
+ def find(id)
63
+ raise NotImplementedError, "#{self.class}#find must be implemented"
64
+ end
65
+
66
+ # Find all approvals for a workflow
67
+ #
68
+ # @param workflow_id [String] The workflow ID
69
+ # @return [Array<Approval>]
70
+ def find_by_workflow(workflow_id)
71
+ raise NotImplementedError, "#{self.class}#find_by_workflow must be implemented"
72
+ end
73
+
74
+ # Find pending approvals for a user
75
+ #
76
+ # @param user_id [String] The user ID
77
+ # @return [Array<Approval>]
78
+ def pending_for_user(user_id)
79
+ raise NotImplementedError, "#{self.class}#pending_for_user must be implemented"
80
+ end
81
+
82
+ # Find all pending approvals
83
+ #
84
+ # @return [Array<Approval>]
85
+ def all_pending
86
+ raise NotImplementedError, "#{self.class}#all_pending must be implemented"
87
+ end
88
+
89
+ # Delete an approval
90
+ #
91
+ # @param id [String] The approval ID
92
+ # @return [Boolean] true if deleted
93
+ def delete(id)
94
+ raise NotImplementedError, "#{self.class}#delete must be implemented"
95
+ end
96
+
97
+ # Delete all approvals (useful for testing)
98
+ #
99
+ # @return [void]
100
+ def clear!
101
+ raise NotImplementedError, "#{self.class}#clear! must be implemented"
102
+ end
103
+ end
104
+
105
+ # In-memory approval store for development and testing
106
+ #
107
+ # Thread-safe storage using a Mutex.
108
+ #
109
+ # @api public
110
+ class MemoryApprovalStore < ApprovalStore
111
+ def initialize
112
+ @approvals = {}
113
+ @mutex = Mutex.new
114
+ end
115
+
116
+ # @see ApprovalStore#save
117
+ def save(approval)
118
+ @mutex.synchronize do
119
+ @approvals[approval.id] = approval
120
+ end
121
+ approval
122
+ end
123
+
124
+ # @see ApprovalStore#find
125
+ def find(id)
126
+ @mutex.synchronize do
127
+ @approvals[id]
128
+ end
129
+ end
130
+
131
+ # @see ApprovalStore#find_by_workflow
132
+ def find_by_workflow(workflow_id)
133
+ @mutex.synchronize do
134
+ @approvals.values.select { |a| a.workflow_id == workflow_id }
135
+ end
136
+ end
137
+
138
+ # @see ApprovalStore#pending_for_user
139
+ def pending_for_user(user_id)
140
+ @mutex.synchronize do
141
+ @approvals.values.select do |a|
142
+ a.pending? && a.can_approve?(user_id)
143
+ end
144
+ end
145
+ end
146
+
147
+ # @see ApprovalStore#all_pending
148
+ def all_pending
149
+ @mutex.synchronize do
150
+ @approvals.values.select(&:pending?)
151
+ end
152
+ end
153
+
154
+ # @see ApprovalStore#delete
155
+ def delete(id)
156
+ @mutex.synchronize do
157
+ !!@approvals.delete(id)
158
+ end
159
+ end
160
+
161
+ # @see ApprovalStore#clear!
162
+ def clear!
163
+ @mutex.synchronize do
164
+ @approvals.clear
165
+ end
166
+ end
167
+
168
+ # Returns the count of stored approvals
169
+ #
170
+ # @return [Integer]
171
+ def count
172
+ @mutex.synchronize do
173
+ @approvals.size
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end