ruby_llm-agents 0.5.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +189 -31
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +136 -16
  4. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +29 -9
  5. data/app/controllers/ruby_llm/agents/workflows_controller.rb +355 -0
  6. data/app/helpers/ruby_llm/agents/application_helper.rb +25 -0
  7. data/app/models/ruby_llm/agents/execution.rb +3 -0
  8. data/app/models/ruby_llm/agents/tenant_budget.rb +58 -15
  9. data/app/services/ruby_llm/agents/agent_registry.rb +51 -12
  10. data/app/views/layouts/ruby_llm/agents/application.html.erb +2 -29
  11. data/app/views/ruby_llm/agents/agents/_agent.html.erb +13 -1
  12. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +235 -0
  13. data/app/views/ruby_llm/agents/agents/_config_embedder.html.erb +70 -0
  14. data/app/views/ruby_llm/agents/agents/_config_image_generator.html.erb +152 -0
  15. data/app/views/ruby_llm/agents/agents/_config_moderator.html.erb +63 -0
  16. data/app/views/ruby_llm/agents/agents/_config_speaker.html.erb +108 -0
  17. data/app/views/ruby_llm/agents/agents/_config_transcriber.html.erb +91 -0
  18. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +1 -1
  19. data/app/views/ruby_llm/agents/agents/index.html.erb +74 -9
  20. data/app/views/ruby_llm/agents/agents/show.html.erb +18 -378
  21. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +269 -15
  22. data/app/views/ruby_llm/agents/executions/show.html.erb +16 -0
  23. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +93 -0
  24. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +236 -0
  25. data/app/views/ruby_llm/agents/workflows/_structure_parallel.html.erb +76 -0
  26. data/app/views/ruby_llm/agents/workflows/_structure_pipeline.html.erb +74 -0
  27. data/app/views/ruby_llm/agents/workflows/_structure_router.html.erb +108 -0
  28. data/app/views/ruby_llm/agents/workflows/show.html.erb +442 -0
  29. data/config/routes.rb +1 -0
  30. data/lib/generators/ruby_llm_agents/agent_generator.rb +56 -7
  31. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +110 -0
  32. data/lib/generators/ruby_llm_agents/embedder_generator.rb +107 -0
  33. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +115 -0
  34. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +108 -0
  35. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +116 -0
  36. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +178 -0
  37. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +109 -0
  38. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +103 -0
  39. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +102 -0
  40. data/lib/generators/ruby_llm_agents/install_generator.rb +76 -4
  41. data/lib/generators/ruby_llm_agents/restructure_generator.rb +292 -0
  42. data/lib/generators/ruby_llm_agents/speaker_generator.rb +121 -0
  43. data/lib/generators/ruby_llm_agents/templates/add_execution_type_migration.rb.tt +8 -0
  44. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +99 -84
  45. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +42 -40
  46. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +26 -0
  47. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +50 -0
  48. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +26 -0
  49. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +20 -0
  50. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +38 -0
  51. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +139 -0
  52. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +21 -0
  53. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +20 -0
  54. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +20 -0
  55. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +49 -0
  56. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +53 -0
  57. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +44 -0
  58. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +41 -0
  59. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +45 -0
  60. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +35 -0
  61. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +47 -0
  62. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +50 -0
  63. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +44 -0
  64. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +38 -0
  65. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +33 -0
  66. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +228 -0
  67. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +131 -0
  68. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +255 -0
  69. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +120 -0
  70. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +102 -0
  71. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +282 -0
  72. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +228 -0
  73. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +120 -0
  74. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +110 -0
  75. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +120 -0
  76. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +212 -0
  77. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +227 -0
  78. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +251 -0
  79. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +300 -0
  80. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +56 -0
  81. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +51 -0
  82. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +107 -0
  83. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +152 -1
  84. data/lib/ruby_llm/agents/audio/speaker.rb +553 -0
  85. data/lib/ruby_llm/agents/audio/transcriber.rb +669 -0
  86. data/lib/ruby_llm/agents/base_agent.rb +675 -0
  87. data/lib/ruby_llm/agents/core/base/moderation_dsl.rb +181 -0
  88. data/lib/ruby_llm/agents/core/base/moderation_execution.rb +274 -0
  89. data/lib/ruby_llm/agents/core/base.rb +135 -0
  90. data/lib/ruby_llm/agents/core/configuration.rb +981 -0
  91. data/lib/ruby_llm/agents/core/errors.rb +150 -0
  92. data/lib/ruby_llm/agents/{instrumentation.rb → core/instrumentation.rb} +22 -1
  93. data/lib/ruby_llm/agents/core/llm_tenant.rb +358 -0
  94. data/lib/ruby_llm/agents/{version.rb → core/version.rb} +1 -1
  95. data/lib/ruby_llm/agents/dsl/base.rb +110 -0
  96. data/lib/ruby_llm/agents/dsl/caching.rb +142 -0
  97. data/lib/ruby_llm/agents/dsl/reliability.rb +307 -0
  98. data/lib/ruby_llm/agents/dsl.rb +41 -0
  99. data/lib/ruby_llm/agents/image/analyzer/dsl.rb +130 -0
  100. data/lib/ruby_llm/agents/image/analyzer/execution.rb +402 -0
  101. data/lib/ruby_llm/agents/image/analyzer.rb +90 -0
  102. data/lib/ruby_llm/agents/image/background_remover/dsl.rb +154 -0
  103. data/lib/ruby_llm/agents/image/background_remover/execution.rb +240 -0
  104. data/lib/ruby_llm/agents/image/background_remover.rb +89 -0
  105. data/lib/ruby_llm/agents/image/concerns/image_operation_dsl.rb +91 -0
  106. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +165 -0
  107. data/lib/ruby_llm/agents/image/editor/dsl.rb +56 -0
  108. data/lib/ruby_llm/agents/image/editor/execution.rb +207 -0
  109. data/lib/ruby_llm/agents/image/editor.rb +92 -0
  110. data/lib/ruby_llm/agents/image/generator/active_storage_support.rb +127 -0
  111. data/lib/ruby_llm/agents/image/generator/content_policy.rb +95 -0
  112. data/lib/ruby_llm/agents/image/generator/pricing.rb +353 -0
  113. data/lib/ruby_llm/agents/image/generator/templates.rb +124 -0
  114. data/lib/ruby_llm/agents/image/generator.rb +455 -0
  115. data/lib/ruby_llm/agents/image/pipeline/dsl.rb +213 -0
  116. data/lib/ruby_llm/agents/image/pipeline/execution.rb +382 -0
  117. data/lib/ruby_llm/agents/image/pipeline.rb +97 -0
  118. data/lib/ruby_llm/agents/image/transformer/dsl.rb +148 -0
  119. data/lib/ruby_llm/agents/image/transformer/execution.rb +223 -0
  120. data/lib/ruby_llm/agents/image/transformer.rb +95 -0
  121. data/lib/ruby_llm/agents/image/upscaler/dsl.rb +83 -0
  122. data/lib/ruby_llm/agents/image/upscaler/execution.rb +219 -0
  123. data/lib/ruby_llm/agents/image/upscaler.rb +81 -0
  124. data/lib/ruby_llm/agents/image/variator/dsl.rb +62 -0
  125. data/lib/ruby_llm/agents/image/variator/execution.rb +189 -0
  126. data/lib/ruby_llm/agents/image/variator.rb +80 -0
  127. data/lib/ruby_llm/agents/{alert_manager.rb → infrastructure/alert_manager.rb} +17 -22
  128. data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +145 -0
  129. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +149 -0
  130. data/lib/ruby_llm/agents/infrastructure/budget/forecaster.rb +68 -0
  131. data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +279 -0
  132. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +275 -0
  133. data/lib/ruby_llm/agents/{execution_logger_job.rb → infrastructure/execution_logger_job.rb} +17 -1
  134. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/executor.rb +2 -1
  135. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/retry_strategy.rb +9 -3
  136. data/lib/ruby_llm/agents/{reliability.rb → infrastructure/reliability.rb} +11 -21
  137. data/lib/ruby_llm/agents/pipeline/builder.rb +215 -0
  138. data/lib/ruby_llm/agents/pipeline/context.rb +255 -0
  139. data/lib/ruby_llm/agents/pipeline/executor.rb +86 -0
  140. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +124 -0
  141. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +95 -0
  142. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +171 -0
  143. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +415 -0
  144. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +276 -0
  145. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +196 -0
  146. data/lib/ruby_llm/agents/pipeline.rb +68 -0
  147. data/lib/ruby_llm/agents/{engine.rb → rails/engine.rb} +79 -11
  148. data/lib/ruby_llm/agents/results/background_removal_result.rb +286 -0
  149. data/lib/ruby_llm/agents/{result.rb → results/base.rb} +73 -1
  150. data/lib/ruby_llm/agents/results/embedding_result.rb +243 -0
  151. data/lib/ruby_llm/agents/results/image_analysis_result.rb +314 -0
  152. data/lib/ruby_llm/agents/results/image_edit_result.rb +250 -0
  153. data/lib/ruby_llm/agents/results/image_generation_result.rb +346 -0
  154. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +399 -0
  155. data/lib/ruby_llm/agents/results/image_transform_result.rb +251 -0
  156. data/lib/ruby_llm/agents/results/image_upscale_result.rb +255 -0
  157. data/lib/ruby_llm/agents/results/image_variation_result.rb +237 -0
  158. data/lib/ruby_llm/agents/results/moderation_result.rb +158 -0
  159. data/lib/ruby_llm/agents/results/speech_result.rb +338 -0
  160. data/lib/ruby_llm/agents/results/transcription_result.rb +408 -0
  161. data/lib/ruby_llm/agents/text/embedder.rb +444 -0
  162. data/lib/ruby_llm/agents/text/moderator.rb +237 -0
  163. data/lib/ruby_llm/agents/workflow/async.rb +220 -0
  164. data/lib/ruby_llm/agents/workflow/async_executor.rb +156 -0
  165. data/lib/ruby_llm/agents/{workflow.rb → workflow/orchestrator.rb} +6 -5
  166. data/lib/ruby_llm/agents/workflow/parallel.rb +34 -17
  167. data/lib/ruby_llm/agents/workflow/thread_pool.rb +185 -0
  168. data/lib/ruby_llm/agents.rb +86 -20
  169. metadata +172 -34
  170. data/lib/ruby_llm/agents/base/caching.rb +0 -40
  171. data/lib/ruby_llm/agents/base/cost_calculation.rb +0 -105
  172. data/lib/ruby_llm/agents/base/dsl.rb +0 -324
  173. data/lib/ruby_llm/agents/base/execution.rb +0 -366
  174. data/lib/ruby_llm/agents/base/reliability_dsl.rb +0 -82
  175. data/lib/ruby_llm/agents/base/reliability_execution.rb +0 -136
  176. data/lib/ruby_llm/agents/base/response_building.rb +0 -86
  177. data/lib/ruby_llm/agents/base/tool_tracking.rb +0 -57
  178. data/lib/ruby_llm/agents/base.rb +0 -210
  179. data/lib/ruby_llm/agents/budget_tracker.rb +0 -733
  180. data/lib/ruby_llm/agents/configuration.rb +0 -394
  181. /data/lib/ruby_llm/agents/{deprecations.rb → core/deprecations.rb} +0 -0
  182. /data/lib/ruby_llm/agents/{inflections.rb → core/inflections.rb} +0 -0
  183. /data/lib/ruby_llm/agents/{resolved_config.rb → core/resolved_config.rb} +0 -0
  184. /data/lib/ruby_llm/agents/{attempt_tracker.rb → infrastructure/attempt_tracker.rb} +0 -0
  185. /data/lib/ruby_llm/agents/{cache_helper.rb → infrastructure/cache_helper.rb} +0 -0
  186. /data/lib/ruby_llm/agents/{circuit_breaker.rb → infrastructure/circuit_breaker.rb} +0 -0
  187. /data/lib/ruby_llm/agents/{redactor.rb → infrastructure/redactor.rb} +0 -0
  188. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/breaker_manager.rb +0 -0
  189. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/execution_constraints.rb +0 -0
  190. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/fallback_routing.rb +0 -0
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # DSL for configuring content moderation on agents
6
+ #
7
+ # Provides declarative configuration for moderating user input
8
+ # and/or LLM output against safety policies.
9
+ #
10
+ # @example Basic input moderation
11
+ # class MyAgent < ApplicationAgent
12
+ # moderation :input
13
+ # end
14
+ #
15
+ # @example Moderate both input and output
16
+ # class MyAgent < ApplicationAgent
17
+ # moderation :both
18
+ # end
19
+ #
20
+ # @example With configuration options
21
+ # class MyAgent < ApplicationAgent
22
+ # moderation :input,
23
+ # model: 'omni-moderation-latest',
24
+ # threshold: 0.8,
25
+ # categories: [:hate, :violence],
26
+ # on_flagged: :raise
27
+ # end
28
+ #
29
+ # @example Block-based DSL
30
+ # class MyAgent < ApplicationAgent
31
+ # moderation do
32
+ # input enabled: true, threshold: 0.7
33
+ # output enabled: true, threshold: 0.9
34
+ # model 'omni-moderation-latest'
35
+ # categories :hate, :violence
36
+ # on_flagged :block
37
+ # end
38
+ # end
39
+ #
40
+ # @api public
41
+ module ModerationDSL
42
+ # Configures content moderation for this agent
43
+ #
44
+ # @param phases [Array<Symbol>] Phases to moderate (:input, :output, :both)
45
+ # @param model [String] Moderation model to use
46
+ # @param threshold [Float] Score threshold (0.0-1.0) for flagging
47
+ # @param categories [Array<Symbol>] Categories to check
48
+ # @param on_flagged [Symbol] Action when flagged (:block, :raise, :warn, :log)
49
+ # @param custom_handler [Symbol] Method name for custom handling
50
+ # @yield Block for advanced configuration
51
+ # @return [Hash, nil] The moderation configuration
52
+ #
53
+ # @example Simple input moderation
54
+ # moderation :input
55
+ #
56
+ # @example Input and output moderation
57
+ # moderation :input, :output
58
+ # # or
59
+ # moderation :both
60
+ #
61
+ # @example With options
62
+ # moderation :input, threshold: 0.8, on_flagged: :raise
63
+ def moderation(*phases, **options, &block)
64
+ if block_given?
65
+ builder = ModerationBuilder.new
66
+ builder.instance_eval(&block)
67
+ @moderation_config = builder.config
68
+ else
69
+ # Handle :both shorthand
70
+ phases = [:input, :output] if phases.include?(:both)
71
+ phases = [:input] if phases.empty?
72
+
73
+ @moderation_config = {
74
+ phases: phases.flatten.map(&:to_sym),
75
+ model: options[:model],
76
+ threshold: options[:threshold],
77
+ categories: options[:categories],
78
+ on_flagged: options[:on_flagged] || :block,
79
+ custom_handler: options[:custom_handler]
80
+ }
81
+ end
82
+ end
83
+
84
+ # Returns the moderation configuration for this agent
85
+ #
86
+ # @return [Hash, nil] The moderation configuration or nil if not configured
87
+ def moderation_config
88
+ @moderation_config || inherited_or_default(:moderation_config, nil)
89
+ end
90
+
91
+ # Returns whether moderation is enabled for this agent
92
+ #
93
+ # @return [Boolean] true if moderation is configured
94
+ def moderation_enabled?
95
+ !!moderation_config
96
+ end
97
+
98
+ private
99
+
100
+ def inherited_or_default(method, default)
101
+ return default unless superclass.respond_to?(method)
102
+
103
+ superclass.send(method)
104
+ end
105
+ end
106
+
107
+ # Builder class for block-based moderation configuration
108
+ #
109
+ # @api private
110
+ class ModerationBuilder
111
+ attr_reader :config
112
+
113
+ def initialize
114
+ @config = {
115
+ phases: [],
116
+ on_flagged: :block
117
+ }
118
+ end
119
+
120
+ # Enables input moderation
121
+ #
122
+ # @param enabled [Boolean] Whether to enable input moderation
123
+ # @param threshold [Float, nil] Score threshold for input phase
124
+ # @return [void]
125
+ def input(enabled: true, threshold: nil)
126
+ @config[:phases] << :input if enabled
127
+ @config[:input_threshold] = threshold if threshold
128
+ end
129
+
130
+ # Enables output moderation
131
+ #
132
+ # @param enabled [Boolean] Whether to enable output moderation
133
+ # @param threshold [Float, nil] Score threshold for output phase
134
+ # @return [void]
135
+ def output(enabled: true, threshold: nil)
136
+ @config[:phases] << :output if enabled
137
+ @config[:output_threshold] = threshold if threshold
138
+ end
139
+
140
+ # Sets the moderation model
141
+ #
142
+ # @param model_name [String] Model identifier
143
+ # @return [void]
144
+ def model(model_name)
145
+ @config[:model] = model_name
146
+ end
147
+
148
+ # Sets the global threshold
149
+ #
150
+ # @param value [Float] Score threshold (0.0-1.0)
151
+ # @return [void]
152
+ def threshold(value)
153
+ @config[:threshold] = value
154
+ end
155
+
156
+ # Sets categories to check
157
+ #
158
+ # @param cats [Array<Symbol>] Category symbols
159
+ # @return [void]
160
+ def categories(*cats)
161
+ @config[:categories] = cats.flatten.map(&:to_sym)
162
+ end
163
+
164
+ # Sets the action when content is flagged
165
+ #
166
+ # @param action [Symbol] :block, :raise, :warn, or :log
167
+ # @return [void]
168
+ def on_flagged(action)
169
+ @config[:on_flagged] = action
170
+ end
171
+
172
+ # Sets a custom handler method
173
+ #
174
+ # @param method_name [Symbol] Method name to call on the agent
175
+ # @return [void]
176
+ def custom_handler(method_name)
177
+ @config[:custom_handler] = method_name
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Execution logic for content moderation
6
+ #
7
+ # Provides methods to check content against moderation policies
8
+ # and handle flagged content according to configuration.
9
+ #
10
+ # @api private
11
+ module ModerationExecution
12
+ # Moderates input text if input moderation is enabled
13
+ #
14
+ # @param text [String] Text to moderate
15
+ # @return [RubyLLM::Moderation, nil] Moderation result or nil if not enabled
16
+ def moderate_input(text)
17
+ return nil unless should_moderate?(:input)
18
+
19
+ perform_moderation(text, :input)
20
+ end
21
+
22
+ # Moderates output text if output moderation is enabled
23
+ #
24
+ # @param text [String] Text to moderate
25
+ # @return [RubyLLM::Moderation, nil] Moderation result or nil if not enabled
26
+ def moderate_output(text)
27
+ return nil unless should_moderate?(:output)
28
+
29
+ perform_moderation(text, :output)
30
+ end
31
+
32
+ # Returns whether moderation was blocked
33
+ #
34
+ # @return [Boolean] true if content was blocked by moderation
35
+ def moderation_blocked?
36
+ @moderation_blocked == true
37
+ end
38
+
39
+ # Returns the phase where moderation blocked content
40
+ #
41
+ # @return [Symbol, nil] :input or :output, or nil if not blocked
42
+ def moderation_blocked_phase
43
+ @moderation_blocked_phase
44
+ end
45
+
46
+ # Returns all moderation results collected during execution
47
+ #
48
+ # @return [Hash{Symbol => RubyLLM::Moderation}] Results keyed by phase
49
+ def moderation_results
50
+ @moderation_results || {}
51
+ end
52
+
53
+ private
54
+
55
+ # Checks if the given phase should be moderated
56
+ #
57
+ # @param phase [Symbol] :input or :output
58
+ # @return [Boolean] true if this phase should be moderated
59
+ def should_moderate?(phase)
60
+ config = resolved_moderation_config
61
+ return false unless config
62
+ return false if @options[:moderation] == false
63
+
64
+ config[:phases].include?(phase)
65
+ end
66
+
67
+ # Resolves the effective moderation configuration
68
+ #
69
+ # Merges class-level configuration with runtime overrides.
70
+ #
71
+ # @return [Hash, nil] Resolved configuration or nil if disabled
72
+ def resolved_moderation_config
73
+ runtime_config = @options[:moderation]
74
+ return nil if runtime_config == false
75
+
76
+ base_config = self.class.moderation_config
77
+ return nil unless base_config
78
+
79
+ if runtime_config.is_a?(Hash)
80
+ base_config.merge(runtime_config)
81
+ else
82
+ base_config
83
+ end
84
+ end
85
+
86
+ # Performs moderation on text
87
+ #
88
+ # @param text [String] Text to moderate
89
+ # @param phase [Symbol] :input or :output
90
+ # @return [RubyLLM::Moderation] The moderation result
91
+ def perform_moderation(text, phase)
92
+ config = resolved_moderation_config
93
+
94
+ moderation_opts = {}
95
+ moderation_opts[:model] = config[:model] if config[:model]
96
+
97
+ result = RubyLLM.moderate(text, **moderation_opts)
98
+
99
+ @moderation_results ||= {}
100
+ @moderation_results[phase] = result
101
+
102
+ record_moderation_execution(result, phase)
103
+
104
+ if content_flagged?(result, config, phase)
105
+ handle_flagged_content(result, config, phase)
106
+ end
107
+
108
+ result
109
+ end
110
+
111
+ # Determines if content should be flagged based on result and config
112
+ #
113
+ # @param result [RubyLLM::Moderation] The moderation result
114
+ # @param config [Hash] Moderation configuration
115
+ # @param phase [Symbol] :input or :output
116
+ # @return [Boolean] true if content should be flagged
117
+ def content_flagged?(result, config, phase)
118
+ return false unless result.flagged?
119
+
120
+ # Check phase-specific or global threshold
121
+ threshold = config[:"#{phase}_threshold"] || config[:threshold]
122
+ if threshold
123
+ max_score = result.category_scores.values.max
124
+ return false if max_score.nil? || max_score < threshold
125
+ end
126
+
127
+ # Check category filter
128
+ if config[:categories]&.any?
129
+ flagged_categories = result.flagged_categories.map { |c| normalize_category(c) }
130
+ allowed_categories = config[:categories].map { |c| normalize_category(c) }
131
+ return false if (flagged_categories & allowed_categories).empty?
132
+ end
133
+
134
+ true
135
+ end
136
+
137
+ # Normalizes category names for comparison
138
+ #
139
+ # @param category [String, Symbol] Category name
140
+ # @return [Symbol] Normalized category symbol
141
+ def normalize_category(category)
142
+ category.to_s.tr("/", "_").tr("-", "_").downcase.to_sym
143
+ end
144
+
145
+ # Handles flagged content according to configuration
146
+ #
147
+ # @param result [RubyLLM::Moderation] The moderation result
148
+ # @param config [Hash] Moderation configuration
149
+ # @param phase [Symbol] :input or :output
150
+ # @return [void]
151
+ def handle_flagged_content(result, config, phase)
152
+ # Custom handler takes priority
153
+ if config[:custom_handler]
154
+ action = send(config[:custom_handler], result, phase)
155
+ return if action == :continue
156
+ end
157
+
158
+ on_flagged = config[:on_flagged] || :block
159
+
160
+ case on_flagged
161
+ when :raise
162
+ raise ModerationError.new(result, phase)
163
+ when :block
164
+ @moderation_blocked = true
165
+ @moderation_blocked_phase = phase
166
+ when :warn
167
+ log_moderation_warning(result, phase)
168
+ when :log
169
+ log_moderation_info(result, phase)
170
+ end
171
+ end
172
+
173
+ # Logs a moderation warning
174
+ #
175
+ # @param result [RubyLLM::Moderation] The moderation result
176
+ # @param phase [Symbol] :input or :output
177
+ # @return [void]
178
+ def log_moderation_warning(result, phase)
179
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
180
+
181
+ Rails.logger.warn(
182
+ "[RubyLLM::Agents] Content flagged in #{phase} moderation: " \
183
+ "#{result.flagged_categories.join(', ')}"
184
+ )
185
+ end
186
+
187
+ # Logs moderation info
188
+ #
189
+ # @param result [RubyLLM::Moderation] The moderation result
190
+ # @param phase [Symbol] :input or :output
191
+ # @return [void]
192
+ def log_moderation_info(result, phase)
193
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
194
+
195
+ Rails.logger.info(
196
+ "[RubyLLM::Agents] Content flagged in #{phase} moderation: " \
197
+ "#{result.flagged_categories.join(', ')}"
198
+ )
199
+ end
200
+
201
+ # Records moderation execution for tracking
202
+ #
203
+ # @param result [RubyLLM::Moderation] The moderation result
204
+ # @param phase [Symbol] :input or :output
205
+ # @return [void]
206
+ def record_moderation_execution(result, phase)
207
+ return unless RubyLLM::Agents.configuration.track_moderation
208
+ return unless execution_model_available?
209
+
210
+ RubyLLM::Agents::Execution.create!(
211
+ agent_type: self.class.name,
212
+ execution_type: "moderation",
213
+ model_id: result.model,
214
+ input_tokens: 0,
215
+ output_tokens: 0,
216
+ total_cost: 0, # Moderation is typically free or very cheap
217
+ duration_ms: 0,
218
+ status: result.flagged? ? "flagged" : "passed",
219
+ metadata: {
220
+ phase: phase,
221
+ flagged: result.flagged?,
222
+ flagged_categories: result.flagged_categories,
223
+ category_scores: result.category_scores
224
+ },
225
+ tenant_id: resolved_tenant_id
226
+ )
227
+ rescue StandardError => e
228
+ Rails.logger.warn("[RubyLLM::Agents] Failed to record moderation: #{e.message}") if defined?(Rails)
229
+ end
230
+
231
+ # Returns the default moderation model
232
+ #
233
+ # @return [String] Default moderation model identifier
234
+ def default_moderation_model
235
+ RubyLLM::Agents.configuration.default_moderation_model || "omni-moderation-latest"
236
+ end
237
+
238
+ # Builds the text to moderate for input phase
239
+ #
240
+ # Combines user prompt content into a single string.
241
+ #
242
+ # @return [String] Text to moderate
243
+ def build_moderation_input
244
+ prompt = user_prompt
245
+ if prompt.is_a?(Array)
246
+ prompt.map { |p| p.is_a?(Hash) ? p[:content] : p.to_s }.join("\n")
247
+ else
248
+ prompt.to_s
249
+ end
250
+ end
251
+
252
+ # Builds a result for blocked moderation
253
+ #
254
+ # @param phase [Symbol] :input or :output
255
+ # @return [Result] Result with moderation blocked status
256
+ def build_moderation_blocked_result(phase)
257
+ Result.new(
258
+ content: nil,
259
+ status: :"#{phase}_moderation_blocked",
260
+ moderation_flagged: true,
261
+ moderation_result: @moderation_results[phase],
262
+ moderation_phase: phase,
263
+ agent_class: self.class.name,
264
+ model_id: model,
265
+ input_tokens: 0,
266
+ output_tokens: 0,
267
+ total_cost: 0,
268
+ started_at: @execution_started_at,
269
+ completed_at: Time.current
270
+ )
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base/moderation_dsl"
4
+ require_relative "base/moderation_execution"
5
+
6
+ module RubyLLM
7
+ module Agents
8
+ # Base class for LLM-powered conversational agents
9
+ #
10
+ # Inherits from BaseAgent to use the middleware pipeline architecture
11
+ # while adding moderation capabilities for input/output content filtering.
12
+ #
13
+ # @example Creating an agent
14
+ # class SearchAgent < ApplicationAgent
15
+ # model "gpt-4o"
16
+ # temperature 0.0
17
+ # version "1.0"
18
+ # timeout 30
19
+ # cache_for 1.hour
20
+ #
21
+ # param :query, required: true
22
+ # param :limit, default: 10
23
+ #
24
+ # def system_prompt
25
+ # "You are a search assistant..."
26
+ # end
27
+ #
28
+ # def user_prompt
29
+ # "Search for: #{query}"
30
+ # end
31
+ # end
32
+ #
33
+ # @example With moderation
34
+ # class SafeAgent < ApplicationAgent
35
+ # moderation :input, :output
36
+ # # or
37
+ # moderation :both
38
+ #
39
+ # def user_prompt
40
+ # query
41
+ # end
42
+ # end
43
+ #
44
+ # @example Calling an agent
45
+ # SearchAgent.call(query: "red dress")
46
+ # SearchAgent.call(query: "red dress", dry_run: true) # Debug mode
47
+ # SearchAgent.call(query: "red dress", skip_cache: true) # Bypass cache
48
+ #
49
+ # @see RubyLLM::Agents::BaseAgent
50
+ # @api public
51
+ class Base < BaseAgent
52
+ extend ModerationDSL
53
+ include ModerationExecution
54
+
55
+ class << self
56
+ # Returns the agent type for conversation agents
57
+ #
58
+ # @return [Symbol] :conversation
59
+ def agent_type
60
+ :conversation
61
+ end
62
+ end
63
+
64
+ # Execute the core LLM call with moderation support
65
+ #
66
+ # This extends BaseAgent's execute method to add input and output
67
+ # moderation checks when configured via the moderation DSL.
68
+ #
69
+ # @param context [Pipeline::Context] The execution context
70
+ # @return [void] Sets context.output with the result
71
+ def execute(context)
72
+ @execution_started_at = context.started_at || Time.current
73
+
74
+ # Input moderation check (before LLM call)
75
+ if self.class.moderation_enabled? && should_moderate?(:input)
76
+ input_text = build_moderation_input
77
+ moderate_input(input_text)
78
+
79
+ if moderation_blocked?
80
+ context.output = build_moderation_blocked_result(:input)
81
+ return
82
+ end
83
+ end
84
+
85
+ # Execute the LLM call via parent
86
+ client = build_client
87
+ response = execute_llm_call(client, context)
88
+ capture_response(response, context)
89
+ processed_content = process_response(response)
90
+
91
+ # Output moderation check (after LLM call)
92
+ if self.class.moderation_enabled? && should_moderate?(:output)
93
+ output_text = processed_content.is_a?(String) ? processed_content : processed_content.to_s
94
+ moderate_output(output_text)
95
+
96
+ if moderation_blocked?
97
+ context.output = build_moderation_blocked_result(:output)
98
+ return
99
+ end
100
+ end
101
+
102
+ context.output = build_result(processed_content, response, context)
103
+ end
104
+
105
+ # Returns the resolved tenant ID for tracking
106
+ #
107
+ # @return [String, nil] The tenant identifier
108
+ def resolved_tenant_id
109
+ tenant = resolve_tenant
110
+ return nil unless tenant
111
+
112
+ tenant.is_a?(Hash) ? tenant[:id]&.to_s : nil
113
+ end
114
+
115
+ private
116
+
117
+ # Check if execution model is available for moderation tracking
118
+ #
119
+ # @return [Boolean] true if Execution model can be used
120
+ def execution_model_available?
121
+ return @execution_model_available if defined?(@execution_model_available)
122
+
123
+ @execution_model_available = begin
124
+ RubyLLM::Agents::Execution.table_exists?
125
+ rescue StandardError
126
+ false
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ # Load moderation modules after class is defined (they reopen the class)
134
+ require_relative "base/moderation_dsl"
135
+ require_relative "base/moderation_execution"