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,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module Pipeline
6
+ module Middleware
7
+ # Resolves tenant context from options and applies API configuration.
8
+ #
9
+ # This middleware extracts tenant information from the context options,
10
+ # sets the tenant_id, tenant_object, and tenant_config on the context,
11
+ # and applies the resolved API configuration to RubyLLM.
12
+ #
13
+ # Supports three formats:
14
+ # - Object with llm_tenant_id method (recommended for ActiveRecord models)
15
+ # - Hash with :id key (simple/legacy format)
16
+ # - nil (no tenant - single-tenant mode)
17
+ #
18
+ # API key resolution priority:
19
+ # 1. Tenant object's llm_api_keys method (if present)
20
+ # 2. Tenant-specific database config (ApiConfiguration)
21
+ # 3. Global database config
22
+ # 4. RubyLLM.configuration (set via initializer or environment)
23
+ #
24
+ # @example With ActiveRecord model
25
+ # # Model uses llm_tenant DSL
26
+ # class Organization < ApplicationRecord
27
+ # include RubyLLM::Agents::LLMTenant
28
+ # llm_tenant id: :slug, api_keys: { openai: :openai_key }
29
+ # end
30
+ #
31
+ # # Pass tenant to agent
32
+ # MyAgent.call(query: "test", tenant: organization)
33
+ #
34
+ # @example With hash
35
+ # MyAgent.call(query: "test", tenant: { id: "org_123" })
36
+ #
37
+ # @example Without tenant
38
+ # MyAgent.call(query: "test") # Single-tenant mode
39
+ #
40
+ class Tenant < Base
41
+ # Process tenant resolution and API key application
42
+ #
43
+ # @param context [Context] The execution context
44
+ # @return [Context] The context with tenant fields populated
45
+ def call(context)
46
+ resolve_tenant!(context)
47
+ apply_api_configuration!(context)
48
+ @app.call(context)
49
+ end
50
+
51
+ private
52
+
53
+ # Resolves tenant context from options
54
+ #
55
+ # @param context [Context] The execution context
56
+ # @raise [ArgumentError] If tenant format is invalid
57
+ def resolve_tenant!(context)
58
+ tenant_value = context.options[:tenant]
59
+
60
+ case tenant_value
61
+ when nil
62
+ # No tenant - single-tenant mode
63
+ context.tenant_id = nil
64
+ context.tenant_object = nil
65
+ context.tenant_config = nil
66
+
67
+ when Hash
68
+ # Hash format: { id: "tenant_id", object: tenant, ... }
69
+ # The :object key is set by BaseAgent.resolve_tenant when tenant object
70
+ # is passed via tenant: param
71
+ context.tenant_id = tenant_value[:id]&.to_s
72
+ context.tenant_object = tenant_value[:object]
73
+ context.tenant_config = tenant_value.except(:id, :object)
74
+
75
+ else
76
+ # Object with llm_tenant_id method
77
+ if tenant_value.respond_to?(:llm_tenant_id)
78
+ context.tenant_id = tenant_value.llm_tenant_id&.to_s
79
+ context.tenant_object = tenant_value
80
+ context.tenant_config = extract_tenant_config(tenant_value)
81
+ else
82
+ raise ArgumentError,
83
+ "tenant must respond to :llm_tenant_id (use llm_tenant DSL), " \
84
+ "or be a Hash with :id key, got #{tenant_value.class}"
85
+ end
86
+ end
87
+ end
88
+
89
+ # Applies API configuration to RubyLLM based on resolved tenant
90
+ #
91
+ # This method resolves API keys from multiple sources and applies
92
+ # them to RubyLLM.config before the agent executes.
93
+ #
94
+ # @param context [Context] The execution context
95
+ def apply_api_configuration!(context)
96
+ # First, try to apply keys from tenant object's llm_api_keys method
97
+ apply_tenant_object_api_keys!(context)
98
+
99
+ # Then, apply database configuration (tenant > global > ruby_llm_config)
100
+ apply_database_api_configuration!(context)
101
+ end
102
+
103
+ # Applies API keys from tenant object's llm_api_keys method
104
+ #
105
+ # @param context [Context] The execution context
106
+ def apply_tenant_object_api_keys!(context)
107
+ tenant_object = context.tenant_object
108
+ return unless tenant_object.respond_to?(:llm_api_keys)
109
+
110
+ api_keys = tenant_object.llm_api_keys
111
+ return if api_keys.blank?
112
+
113
+ apply_api_keys_to_ruby_llm(api_keys)
114
+ rescue StandardError => e
115
+ # Log but don't fail if API key extraction fails
116
+ warn_api_key_error("tenant object", e)
117
+ end
118
+
119
+ # Applies API configuration from the database
120
+ #
121
+ # @param context [Context] The execution context
122
+ def apply_database_api_configuration!(context)
123
+ return unless api_configuration_available?
124
+
125
+ resolved = ApiConfiguration.resolve(tenant_id: context.tenant_id)
126
+ resolved.apply_to_ruby_llm!
127
+
128
+ # Store resolved config on context for observability
129
+ context[:resolved_api_config] = resolved
130
+ rescue StandardError => e
131
+ # Log but don't fail if DB lookup fails
132
+ warn_api_key_error("database", e)
133
+ end
134
+
135
+ # Applies a hash of API keys to RubyLLM configuration
136
+ #
137
+ # @param api_keys [Hash] Hash of provider => key mappings
138
+ def apply_api_keys_to_ruby_llm(api_keys)
139
+ RubyLLM.configure do |config|
140
+ api_keys.each do |provider, key|
141
+ next if key.blank?
142
+
143
+ setter = api_key_setter_for(provider)
144
+ config.public_send(setter, key) if config.respond_to?(setter)
145
+ end
146
+ end
147
+ end
148
+
149
+ # Returns the setter method name for a provider's API key
150
+ #
151
+ # @param provider [Symbol, String] Provider name (e.g., :openai, :anthropic)
152
+ # @return [String] Setter method name (e.g., "openai_api_key=")
153
+ def api_key_setter_for(provider)
154
+ "#{provider}_api_key="
155
+ end
156
+
157
+ # Checks if ApiConfiguration model is available
158
+ #
159
+ # @return [Boolean]
160
+ def api_configuration_available?
161
+ return false unless defined?(RubyLLM::Agents::ApiConfiguration)
162
+
163
+ # Check if table exists
164
+ ApiConfiguration.table_exists?
165
+ rescue StandardError
166
+ false
167
+ end
168
+
169
+ # Logs a warning about API key resolution failure
170
+ #
171
+ # @param source [String] Source that failed
172
+ # @param error [StandardError] The error
173
+ def warn_api_key_error(source, error)
174
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
175
+
176
+ Rails.logger.warn(
177
+ "[RubyLLM::Agents] Failed to resolve API keys from #{source}: #{error.message}"
178
+ )
179
+ end
180
+
181
+ # Extracts additional configuration from tenant object
182
+ #
183
+ # @param tenant [Object] The tenant object
184
+ # @return [Hash, nil] Additional configuration or nil
185
+ def extract_tenant_config(tenant)
186
+ return nil unless tenant.respond_to?(:llm_config)
187
+
188
+ tenant.llm_config
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pipeline infrastructure for middleware-based agent execution
4
+ #
5
+ # The pipeline provides a clean separation of concerns through middleware:
6
+ # - Context: Carries data through the pipeline
7
+ # - Middleware: Wraps execution with cross-cutting concerns
8
+ # - Builder: Constructs the middleware stack
9
+ # - Executor: Adapts agent execution to the pipeline interface
10
+ #
11
+ # @example Basic pipeline usage
12
+ # # Build a pipeline for an agent class
13
+ # pipeline = Pipeline::Builder.for(MyEmbedder).build(
14
+ # Pipeline::Executor.new(agent_instance)
15
+ # )
16
+ #
17
+ # # Create a context and execute
18
+ # context = Pipeline::Context.new(
19
+ # input: "Hello world",
20
+ # agent_class: MyEmbedder
21
+ # )
22
+ # result_context = pipeline.call(context)
23
+ #
24
+ # @see Pipeline::Context
25
+ # @see Pipeline::Builder
26
+ # @see Pipeline::Middleware::Base
27
+ #
28
+ require_relative "pipeline/context"
29
+ require_relative "pipeline/executor"
30
+ require_relative "pipeline/builder"
31
+
32
+ # Middleware classes
33
+ require_relative "pipeline/middleware/base"
34
+ require_relative "pipeline/middleware/tenant"
35
+ require_relative "pipeline/middleware/budget"
36
+ require_relative "pipeline/middleware/cache"
37
+ require_relative "pipeline/middleware/instrumentation"
38
+ require_relative "pipeline/middleware/reliability"
39
+
40
+ module RubyLLM
41
+ module Agents
42
+ module Pipeline
43
+ class << self
44
+ # Build a pipeline for an agent class with default middleware
45
+ #
46
+ # This is a convenience method that combines Builder.for with build.
47
+ #
48
+ # @param agent_class [Class] The agent class
49
+ # @param executor [#call] The core executor
50
+ # @return [#call] The complete pipeline
51
+ def build(agent_class, executor)
52
+ Builder.for(agent_class).build(executor)
53
+ end
54
+
55
+ # Build an empty pipeline (no middleware)
56
+ #
57
+ # Useful for testing or when you want direct execution.
58
+ #
59
+ # @param agent_class [Class] The agent class
60
+ # @param executor [#call] The core executor
61
+ # @return [#call] The executor (no middleware wrapping)
62
+ def build_empty(agent_class, executor)
63
+ Builder.empty(agent_class).build(executor)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -32,11 +32,11 @@ module RubyLLM
32
32
  #
33
33
  # @api private
34
34
  config.to_prepare do
35
- require_relative "execution_logger_job"
36
- require_relative "instrumentation"
37
- require_relative "resolved_config"
38
- require_relative "base"
39
- require_relative "workflow"
35
+ require_relative "../infrastructure/execution_logger_job"
36
+ require_relative "../core/instrumentation"
37
+ require_relative "../core/resolved_config"
38
+ require_relative "../core/base"
39
+ require_relative "../workflow/orchestrator"
40
40
 
41
41
  # Resolve the parent controller class from configuration
42
42
  # Default is ActionController::Base, but can be set to inherit from app controllers
@@ -170,16 +170,84 @@ module RubyLLM
170
170
  g.factory_bot dir: "spec/factories"
171
171
  end
172
172
 
173
- # Adds the host app's app/agents directory to Rails autoload paths
173
+ # Adds the host app's LLM directories to Rails autoload paths
174
174
  #
175
- # This allows agent classes defined in app/agents/ to be automatically
176
- # loaded without explicit requires. Must run before set_autoload_paths.
175
+ # This allows agent classes and other LLM components defined in app/llm/
176
+ # to be automatically loaded without explicit requires.
177
+ #
178
+ # Supports two structures:
179
+ # 1. New grouped structure: app/llm/agents/, app/llm/tools/, etc.
180
+ # 2. Legacy flat structure: app/agents/ (for backwards compatibility)
177
181
  #
178
182
  # @api private
179
183
  initializer "ruby_llm_agents.autoload_agents", before: :set_autoload_paths do |app|
180
- agents_path = app.root.join("app/agents")
181
- if agents_path.exist?
182
- Rails.autoloaders.main.push_dir(agents_path.to_s)
184
+ config = RubyLLM::Agents.configuration
185
+
186
+ # Check for new grouped structure (app/llm/*)
187
+ root_path = app.root.join("app", config.root_directory)
188
+ if root_path.exist?
189
+ # Add each configured path that exists
190
+ config.all_autoload_paths.each do |relative_path|
191
+ full_path = app.root.join(relative_path)
192
+ if full_path.exist?
193
+ # Configure namespace for the path
194
+ namespace = self.class.namespace_for_path(relative_path, config)
195
+ if namespace
196
+ Rails.autoloaders.main.push_dir(full_path.to_s, namespace: namespace)
197
+ else
198
+ Rails.autoloaders.main.push_dir(full_path.to_s)
199
+ end
200
+ end
201
+ end
202
+ else
203
+ # Fallback to legacy flat structure (app/agents/)
204
+ agents_path = app.root.join("app/agents")
205
+ if agents_path.exist?
206
+ Rails.autoloaders.main.push_dir(agents_path.to_s)
207
+ end
208
+ end
209
+ end
210
+
211
+ # Determines the namespace constant for a given path
212
+ #
213
+ # @param path [String] Relative path like "app/llm/agents"
214
+ # @param config [Configuration] Current configuration
215
+ # @return [Module, nil] Namespace module or nil for top-level
216
+ # @api private
217
+ def self.namespace_for_path(path, config)
218
+ # Parse the path to determine namespace
219
+ parts = path.split("/")
220
+ return nil unless parts.length >= 3
221
+
222
+ category = parts[2] # e.g., "agents", "audio", "image", "text"
223
+
224
+ # Determine the namespace name based on category and root_namespace setting
225
+ namespace_name = if config.root_namespace.blank?
226
+ # No root namespace - use category namespace only for audio/image/text
227
+ case category
228
+ when "audio", "image", "text"
229
+ category.camelize # "Audio", "Image", "Text"
230
+ else
231
+ nil # Top-level for agents, workflows, tools
232
+ end
233
+ else
234
+ # With root namespace - prefix category with root namespace
235
+ case category
236
+ when "audio", "image", "text"
237
+ "#{config.root_namespace}::#{category.camelize}"
238
+ else
239
+ config.root_namespace
240
+ end
241
+ end
242
+
243
+ return nil if namespace_name.nil?
244
+
245
+ # Return the constant, creating intermediate modules if needed
246
+ namespace_name.constantize
247
+ rescue NameError
248
+ # Create the namespace module if it doesn't exist
249
+ namespace_name.split("::").inject(Object) do |mod, name|
250
+ mod.const_defined?(name, false) ? mod.const_get(name) : mod.const_set(name, Module.new)
183
251
  end
184
252
  end
185
253
  end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Result wrapper for background removal operations
6
+ #
7
+ # Provides a consistent interface for accessing the extracted foreground,
8
+ # segmentation mask, and metadata.
9
+ #
10
+ # @example Accessing removal result
11
+ # result = BackgroundRemover.call(image: "photo.jpg")
12
+ # result.url # => "https://..." (transparent PNG)
13
+ # result.has_alpha? # => true
14
+ # result.mask # => Segmentation mask (if requested)
15
+ # result.success? # => true
16
+ #
17
+ class BackgroundRemovalResult
18
+ attr_reader :foreground, :mask, :source_image, :model_id, :output_format,
19
+ :alpha_matting, :refine_edges,
20
+ :started_at, :completed_at, :tenant_id, :remover_class,
21
+ :error_class, :error_message
22
+
23
+ # Initialize a new result
24
+ #
25
+ # @param foreground [Object] The extracted foreground image
26
+ # @param mask [Object, nil] The segmentation mask (if requested)
27
+ # @param source_image [String] The original source image
28
+ # @param model_id [String] Model used for removal
29
+ # @param output_format [Symbol] Output format used
30
+ # @param alpha_matting [Boolean] Whether alpha matting was used
31
+ # @param refine_edges [Boolean] Whether edge refinement was used
32
+ # @param started_at [Time] When removal started
33
+ # @param completed_at [Time] When removal completed
34
+ # @param tenant_id [String, nil] Tenant identifier
35
+ # @param remover_class [String] Name of the remover class
36
+ # @param error_class [String, nil] Error class name if failed
37
+ # @param error_message [String, nil] Error message if failed
38
+ def initialize(foreground:, mask:, source_image:, model_id:, output_format:,
39
+ alpha_matting:, refine_edges:, started_at:, completed_at:,
40
+ tenant_id:, remover_class:, error_class: nil, error_message: nil)
41
+ @foreground = foreground
42
+ @mask = mask
43
+ @source_image = source_image
44
+ @model_id = model_id
45
+ @output_format = output_format
46
+ @alpha_matting = alpha_matting
47
+ @refine_edges = refine_edges
48
+ @started_at = started_at
49
+ @completed_at = completed_at
50
+ @tenant_id = tenant_id
51
+ @remover_class = remover_class
52
+ @error_class = error_class
53
+ @error_message = error_message
54
+ end
55
+
56
+ # Status helpers
57
+
58
+ def success?
59
+ error_class.nil? && !foreground.nil?
60
+ end
61
+
62
+ def error?
63
+ !success?
64
+ end
65
+
66
+ # Always single for background removal
67
+ def single?
68
+ true
69
+ end
70
+
71
+ def batch?
72
+ false
73
+ end
74
+
75
+ # Image access (foreground)
76
+
77
+ def image
78
+ foreground
79
+ end
80
+
81
+ def url
82
+ foreground&.url
83
+ end
84
+
85
+ def urls
86
+ success? ? [url].compact : []
87
+ end
88
+
89
+ def data
90
+ foreground&.data
91
+ end
92
+
93
+ def datas
94
+ success? ? [data].compact : []
95
+ end
96
+
97
+ def base64?
98
+ foreground&.base64? || false
99
+ end
100
+
101
+ def mime_type
102
+ foreground&.mime_type || "image/#{output_format}"
103
+ end
104
+
105
+ # Mask access
106
+
107
+ def mask?
108
+ !mask.nil?
109
+ end
110
+
111
+ def mask_url
112
+ mask&.url
113
+ end
114
+
115
+ def mask_data
116
+ mask&.data
117
+ end
118
+
119
+ # Check if result has alpha channel (transparency)
120
+ def has_alpha?
121
+ return false if error?
122
+
123
+ # PNG and WebP support alpha
124
+ %i[png webp].include?(output_format)
125
+ end
126
+
127
+ # Count (always 1 for removal)
128
+
129
+ def count
130
+ success? ? 1 : 0
131
+ end
132
+
133
+ # Timing
134
+
135
+ def duration_ms
136
+ return 0 unless started_at && completed_at
137
+ ((completed_at - started_at) * 1000).round
138
+ end
139
+
140
+ # Cost estimation
141
+
142
+ def total_cost
143
+ return 0 if error?
144
+
145
+ # Background removal typically has fixed per-image cost
146
+ ImageGenerator::Pricing.calculate_cost(
147
+ model_id: model_id,
148
+ count: 1
149
+ )
150
+ end
151
+
152
+ # File operations
153
+
154
+ def save(path)
155
+ raise "No foreground image to save" unless foreground
156
+ foreground.save(path)
157
+ end
158
+
159
+ def save_mask(path)
160
+ raise "No mask to save" unless mask
161
+ mask.save(path)
162
+ end
163
+
164
+ def to_blob
165
+ foreground&.to_blob
166
+ end
167
+
168
+ def mask_blob
169
+ mask&.to_blob
170
+ end
171
+
172
+ def blobs
173
+ success? ? [to_blob].compact : []
174
+ end
175
+
176
+ # Serialization
177
+
178
+ def to_h
179
+ {
180
+ success: success?,
181
+ url: url,
182
+ mask_url: mask_url,
183
+ base64: base64?,
184
+ mime_type: mime_type,
185
+ has_alpha: has_alpha?,
186
+ has_mask: mask?,
187
+ source_image: source_image,
188
+ model_id: model_id,
189
+ output_format: output_format,
190
+ alpha_matting: alpha_matting,
191
+ refine_edges: refine_edges,
192
+ total_cost: total_cost,
193
+ duration_ms: duration_ms,
194
+ started_at: started_at&.iso8601,
195
+ completed_at: completed_at&.iso8601,
196
+ tenant_id: tenant_id,
197
+ remover_class: remover_class,
198
+ error_class: error_class,
199
+ error_message: error_message
200
+ }
201
+ end
202
+
203
+ # Caching
204
+
205
+ def to_cache
206
+ {
207
+ url: url,
208
+ data: data,
209
+ mask_url: mask_url,
210
+ mask_data: mask_data,
211
+ mime_type: mime_type,
212
+ model_id: model_id,
213
+ output_format: output_format,
214
+ total_cost: total_cost,
215
+ cached_at: Time.current.iso8601
216
+ }
217
+ end
218
+
219
+ def self.from_cache(data)
220
+ CachedBackgroundRemovalResult.new(data)
221
+ end
222
+ end
223
+
224
+ # Lightweight result for cached removals
225
+ class CachedBackgroundRemovalResult
226
+ attr_reader :url, :data, :mask_url, :mask_data, :mime_type, :model_id,
227
+ :output_format, :total_cost, :cached_at
228
+
229
+ def initialize(data)
230
+ @url = data[:url]
231
+ @data = data[:data]
232
+ @mask_url = data[:mask_url]
233
+ @mask_data = data[:mask_data]
234
+ @mime_type = data[:mime_type]
235
+ @model_id = data[:model_id]
236
+ @output_format = data[:output_format]
237
+ @total_cost = data[:total_cost]
238
+ @cached_at = data[:cached_at]
239
+ end
240
+
241
+ def success?
242
+ !url.nil? || !data.nil?
243
+ end
244
+
245
+ def error?
246
+ !success?
247
+ end
248
+
249
+ def cached?
250
+ true
251
+ end
252
+
253
+ def urls
254
+ success? ? [url].compact : []
255
+ end
256
+
257
+ def datas
258
+ success? ? [data].compact : []
259
+ end
260
+
261
+ def base64?
262
+ !data.nil?
263
+ end
264
+
265
+ def mask?
266
+ !mask_url.nil? || !mask_data.nil?
267
+ end
268
+
269
+ def has_alpha?
270
+ %i[png webp].include?(output_format&.to_sym)
271
+ end
272
+
273
+ def count
274
+ success? ? 1 : 0
275
+ end
276
+
277
+ def single?
278
+ true
279
+ end
280
+
281
+ def batch?
282
+ false
283
+ end
284
+ end
285
+ end
286
+ end