ruby_llm-agents 1.0.0.beta.1 → 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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +9 -3
- data/app/controllers/concerns/ruby_llm/agents/sortable.rb +58 -0
- data/app/controllers/ruby_llm/agents/agents_controller.rb +59 -16
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +144 -20
- data/app/controllers/ruby_llm/agents/executions_controller.rb +13 -16
- data/app/controllers/ruby_llm/agents/workflows_controller.rb +279 -90
- data/app/helpers/ruby_llm/agents/application_helper.rb +100 -0
- data/app/mailers/ruby_llm/agents/alert_mailer.rb +84 -0
- data/app/mailers/ruby_llm/agents/application_mailer.rb +28 -0
- data/app/models/ruby_llm/agents/execution/analytics.rb +170 -20
- data/app/models/ruby_llm/agents/execution/scopes.rb +0 -31
- data/app/models/ruby_llm/agents/execution/workflow.rb +0 -129
- data/app/models/ruby_llm/agents/execution.rb +50 -14
- data/app/services/ruby_llm/agents/agent_registry.rb +18 -12
- data/app/views/layouts/ruby_llm/agents/application.html.erb +72 -76
- data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -12
- data/app/views/ruby_llm/agents/agents/_sortable_header.html.erb +56 -0
- data/app/views/ruby_llm/agents/agents/_workflow.html.erb +5 -15
- data/app/views/ruby_llm/agents/agents/index.html.erb +271 -100
- data/app/views/ruby_llm/agents/agents/show.html.erb +1 -0
- data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +107 -0
- data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +18 -0
- data/app/views/ruby_llm/agents/api_configurations/show.html.erb +4 -1
- data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +66 -359
- data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +56 -0
- data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +115 -0
- data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +35 -60
- data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +17 -6
- data/app/views/ruby_llm/agents/dashboard/index.html.erb +373 -72
- data/app/views/ruby_llm/agents/executions/_execution.html.erb +0 -1
- data/app/views/ruby_llm/agents/executions/_filters.html.erb +51 -39
- data/app/views/ruby_llm/agents/executions/_list.html.erb +53 -195
- data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +5 -20
- data/app/views/ruby_llm/agents/executions/index.html.erb +7 -83
- data/app/views/ruby_llm/agents/executions/show.html.erb +10 -20
- data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +2 -1
- data/app/views/ruby_llm/agents/shared/_doc_link.html.erb +12 -0
- data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +3 -15
- data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_sortable_header.html.erb +53 -0
- data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +7 -0
- data/app/views/ruby_llm/agents/shared/_status_dot.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +9 -35
- data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -1
- data/app/views/ruby_llm/agents/tenants/index.html.erb +4 -1
- data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +7 -15
- data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +539 -0
- data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +920 -0
- data/app/views/ruby_llm/agents/workflows/index.html.erb +179 -0
- data/app/views/ruby_llm/agents/workflows/show.html.erb +164 -139
- data/config/routes.rb +1 -1
- data/lib/generators/ruby_llm_agents/agent_generator.rb +6 -36
- data/lib/generators/ruby_llm_agents/background_remover_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/embedder_generator.rb +5 -38
- data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_editor_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_generator_generator.rb +8 -41
- data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +18 -46
- data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_variator_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/install_generator.rb +33 -56
- data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +480 -0
- data/lib/generators/ruby_llm_agents/restructure_generator.rb +2 -2
- data/lib/generators/ruby_llm_agents/speaker_generator.rb +8 -39
- data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +5 -8
- data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +40 -42
- data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +24 -26
- data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +19 -17
- data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +31 -33
- data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +125 -127
- data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +20 -18
- data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +19 -17
- data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +19 -17
- data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +38 -40
- data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +42 -44
- data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +48 -0
- data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +19 -21
- data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +19 -21
- data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +15 -17
- data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +25 -27
- data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +19 -21
- data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +17 -19
- data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +15 -17
- data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +87 -24
- data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +21 -27
- data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +46 -54
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +31 -39
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +22 -28
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +53 -63
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +46 -56
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +23 -31
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +22 -30
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +23 -31
- data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +38 -46
- data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +7 -7
- data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +59 -71
- data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +274 -23
- data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +29 -31
- data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +28 -30
- data/lib/generators/ruby_llm_agents/transcriber_generator.rb +10 -43
- data/lib/ruby_llm/agents/core/configuration.rb +55 -43
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +26 -0
- data/lib/ruby_llm/agents/pipeline.rb +69 -0
- data/lib/ruby_llm/agents/workflow/approval.rb +205 -0
- data/lib/ruby_llm/agents/workflow/approval_store.rb +179 -0
- data/lib/ruby_llm/agents/workflow/dsl/executor.rb +467 -0
- data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +244 -0
- data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +289 -0
- data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +107 -0
- data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +150 -0
- data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +187 -0
- data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +352 -0
- data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +415 -0
- data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +257 -0
- data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +317 -0
- data/lib/ruby_llm/agents/workflow/dsl.rb +576 -0
- data/lib/ruby_llm/agents/workflow/instrumentation.rb +2 -7
- data/lib/ruby_llm/agents/workflow/notifiers/base.rb +117 -0
- data/lib/ruby_llm/agents/workflow/notifiers/email.rb +117 -0
- data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +180 -0
- data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +121 -0
- data/lib/ruby_llm/agents/workflow/notifiers.rb +70 -0
- data/lib/ruby_llm/agents/workflow/orchestrator.rb +190 -23
- data/lib/ruby_llm/agents/workflow/result.rb +202 -0
- data/lib/ruby_llm/agents/workflow/throttle_manager.rb +206 -0
- data/lib/ruby_llm/agents/workflow/wait_result.rb +213 -0
- metadata +37 -6
- data/app/views/ruby_llm/agents/dashboard/_execution_item.html.erb +0 -66
- data/lib/ruby_llm/agents/workflow/parallel.rb +0 -299
- data/lib/ruby_llm/agents/workflow/pipeline.rb +0 -306
- 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 = "
|
|
706
|
-
@root_namespace =
|
|
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
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
#
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
#
|
|
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
|
|
|
@@ -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
|