ruby_llm-agents 0.5.0 → 1.0.0.beta.1

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,415 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module Pipeline
6
+ module Middleware
7
+ # Times execution and records results for observability.
8
+ #
9
+ # This middleware provides:
10
+ # - Execution timing (start/end timestamps, duration)
11
+ # - Success/failure recording to database
12
+ # - Token usage and cost tracking
13
+ # - Error details on failure
14
+ #
15
+ # Recording can be async (via background job) or sync depending
16
+ # on configuration.
17
+ #
18
+ # Tracking is enabled/disabled per agent type via configuration:
19
+ # - track_executions (conversation agents)
20
+ # - track_embeddings
21
+ # - track_moderations
22
+ # - track_image_generations
23
+ # - track_audio
24
+ #
25
+ # @example Configuration
26
+ # RubyLLM::Agents.configure do |config|
27
+ # config.track_executions = true
28
+ # config.track_embeddings = true
29
+ # config.async_logging = true # Use background job
30
+ # end
31
+ #
32
+ class Instrumentation < Base
33
+ # Process instrumentation
34
+ #
35
+ # Creates a "running" execution record at the start so executions
36
+ # appear on the dashboard immediately, then updates it when complete.
37
+ #
38
+ # @param context [Context] The execution context
39
+ # @return [Context] The context with timing info
40
+ def call(context)
41
+ context.started_at = Time.current
42
+
43
+ # Create "running" record immediately (SYNC - must appear on dashboard)
44
+ execution = create_running_execution(context)
45
+ context.execution_id = execution&.id
46
+ status_update_completed = false
47
+ raised_exception = nil
48
+
49
+ begin
50
+ @app.call(context)
51
+ context.completed_at = Time.current
52
+
53
+ begin
54
+ complete_execution(execution, context, status: "success")
55
+ status_update_completed = true
56
+ rescue StandardError
57
+ # Let ensure block handle via mark_execution_failed!
58
+ end
59
+ rescue StandardError => e
60
+ context.completed_at = Time.current
61
+ context.error = e
62
+ raised_exception = e
63
+
64
+ begin
65
+ complete_execution(execution, context, status: determine_error_status(e))
66
+ status_update_completed = true
67
+ rescue StandardError
68
+ # Let ensure block handle via mark_execution_failed!
69
+ end
70
+
71
+ raise
72
+ ensure
73
+ # Emergency fallback if update failed
74
+ mark_execution_failed!(execution, error: raised_exception || $!) unless status_update_completed
75
+ end
76
+
77
+ context
78
+ end
79
+
80
+ private
81
+
82
+ # Creates initial execution record with 'running' status
83
+ #
84
+ # Creates the record synchronously so it appears on the dashboard immediately.
85
+ # Returns nil on failure to avoid breaking the actual execution.
86
+ #
87
+ # @param context [Context] The execution context
88
+ # @return [Execution, nil] The created record, or nil on failure
89
+ def create_running_execution(context)
90
+ return nil unless tracking_enabled?(context)
91
+ return nil unless execution_model_available?
92
+ return nil if context.cached? && !track_cache_hits?
93
+
94
+ data = build_running_execution_data(context)
95
+ Execution.create!(data)
96
+ rescue StandardError => e
97
+ error("Failed to create running execution record: #{e.message}")
98
+ nil
99
+ end
100
+
101
+ # Updates execution record with completion data
102
+ #
103
+ # Updates the existing record with final status, duration, and metrics.
104
+ # Falls back to creating a new record if the initial record is nil.
105
+ # Errors are re-raised to allow the ensure block to handle them.
106
+ #
107
+ # @param execution [Execution, nil] The execution record to update
108
+ # @param context [Context] The execution context
109
+ # @param status [String] Final status ("success", "error", "timeout")
110
+ # @raise [StandardError] Re-raises any errors for ensure block to handle
111
+ def complete_execution(execution, context, status:)
112
+ return unless tracking_enabled?(context)
113
+ return if context.cached? && !track_cache_hits?
114
+ return unless execution_model_available?
115
+
116
+ # Fall back to legacy create if no execution record exists
117
+ unless execution
118
+ persist_execution(context, status: status)
119
+ return
120
+ end
121
+
122
+ update_data = build_completion_data(context, status)
123
+
124
+ if async_logging?
125
+ # For async updates, use a job (if update support exists)
126
+ # For now, update synchronously to ensure dashboard shows correct status
127
+ execution.update!(update_data)
128
+ else
129
+ execution.update!(update_data)
130
+ end
131
+ rescue StandardError => e
132
+ error("Failed to complete execution record: #{e.message}")
133
+ raise # Re-raise for ensure block to handle via mark_execution_failed!
134
+ end
135
+
136
+ # Emergency fallback to mark execution as failed
137
+ #
138
+ # Uses update_all to bypass ActiveRecord callbacks and validations,
139
+ # ensuring the status is updated even if the model is in an invalid state.
140
+ # Only updates records that are still in 'running' status.
141
+ #
142
+ # @param execution [Execution, nil] The execution record
143
+ # @param error [Exception, nil] The exception that caused the failure
144
+ def mark_execution_failed!(execution, error: nil)
145
+ return unless execution&.id
146
+ return unless execution.status == "running"
147
+
148
+ error_message = error ? "#{error.class}: #{error.message}".truncate(1000) : "Unknown error"
149
+
150
+ update_data = {
151
+ status: "error",
152
+ completed_at: Time.current,
153
+ error_class: error&.class&.name || "UnknownError",
154
+ error_message: error_message
155
+ }
156
+
157
+ execution.class.where(id: execution.id, status: "running").update_all(update_data)
158
+ rescue StandardError => e
159
+ error("CRITICAL: Failed emergency status update for execution #{execution&.id}: #{e.message}")
160
+ end
161
+
162
+ # Determines the status based on error type
163
+ #
164
+ # @param error [Exception] The exception that occurred
165
+ # @return [String] The determined status ("timeout" or "error")
166
+ def determine_error_status(error)
167
+ error.is_a?(Timeout::Error) ? "timeout" : "error"
168
+ end
169
+
170
+ # Builds data for initial running execution record
171
+ #
172
+ # @param context [Context] The execution context
173
+ # @return [Hash] Execution data for creating running record
174
+ def build_running_execution_data(context)
175
+ data = {
176
+ agent_type: context.agent_class&.name,
177
+ agent_version: config(:version, "1.0"),
178
+ model_id: context.model,
179
+ status: "running",
180
+ started_at: context.started_at,
181
+ input_tokens: 0,
182
+ output_tokens: 0,
183
+ total_cost: 0,
184
+ attempts_count: context.attempts_made
185
+ }
186
+
187
+ # Add tenant_id only if multi-tenancy is enabled and tenant is set
188
+ if global_config.multi_tenancy_enabled? && context.tenant_id.present?
189
+ data[:tenant_id] = context.tenant_id
190
+ end
191
+
192
+ # Add sanitized parameters
193
+ data[:parameters] = sanitize_parameters(context)
194
+
195
+ data
196
+ end
197
+
198
+ # Builds data for completing an execution record
199
+ #
200
+ # @param context [Context] The execution context
201
+ # @param status [String] Final status ("success", "error", "timeout")
202
+ # @return [Hash] Update data for completing the record
203
+ def build_completion_data(context, status)
204
+ data = {
205
+ status: status,
206
+ completed_at: context.completed_at,
207
+ duration_ms: context.duration_ms,
208
+ cache_hit: context.cached?,
209
+ input_tokens: context.input_tokens || 0,
210
+ output_tokens: context.output_tokens || 0,
211
+ total_cost: context.total_cost || 0,
212
+ attempts_count: context.attempts_made
213
+ }
214
+
215
+ # Add cache key for cache hit executions
216
+ if context.cached? && context[:cache_key]
217
+ data[:response_cache_key] = context[:cache_key]
218
+ end
219
+
220
+ # Add error details if present
221
+ if context.error
222
+ data[:error_class] = context.error.class.name
223
+ data[:error_message] = truncate_error_message(context.error.message)
224
+ end
225
+
226
+ # Add custom metadata
227
+ data[:metadata] = context.metadata if context.metadata.any?
228
+
229
+ data
230
+ end
231
+
232
+ # Persists execution data to database (legacy fallback)
233
+ #
234
+ # Used when initial running record creation failed.
235
+ #
236
+ # @param context [Context] The execution context
237
+ # @param status [String] "success" or "error"
238
+ def persist_execution(context, status:)
239
+ return unless execution_model_available?
240
+
241
+ data = build_execution_data(context, status)
242
+
243
+ if async_logging?
244
+ queue_async_logging(data)
245
+ else
246
+ create_execution_record(data)
247
+ end
248
+ rescue StandardError => e
249
+ error("Failed to record execution: #{e.message}")
250
+ end
251
+
252
+ # Builds execution data hash
253
+ #
254
+ # @param context [Context] The execution context
255
+ # @param status [String] "success" or "error"
256
+ # @return [Hash] Execution data
257
+ def build_execution_data(context, status)
258
+ data = {
259
+ agent_type: context.agent_class&.name,
260
+ agent_version: config(:version, "1.0"),
261
+ model_id: context.model,
262
+ status: determine_status(context, status),
263
+ duration_ms: context.duration_ms,
264
+ started_at: context.started_at,
265
+ completed_at: context.completed_at,
266
+ cache_hit: context.cached?,
267
+ input_tokens: context.input_tokens || 0,
268
+ output_tokens: context.output_tokens || 0,
269
+ total_cost: context.total_cost || 0,
270
+ attempts_count: context.attempts_made
271
+ }
272
+
273
+ # Add tenant_id only if multi-tenancy is enabled and tenant is set
274
+ if global_config.multi_tenancy_enabled? && context.tenant_id.present?
275
+ data[:tenant_id] = context.tenant_id
276
+ end
277
+
278
+ # Add cache key for cache hit executions
279
+ if context.cached? && context[:cache_key]
280
+ data[:response_cache_key] = context[:cache_key]
281
+ end
282
+
283
+ # Add error details if present
284
+ if context.error
285
+ data[:error_class] = context.error.class.name
286
+ data[:error_message] = truncate_error_message(context.error.message)
287
+ end
288
+
289
+ # Add custom metadata
290
+ data[:metadata] = context.metadata if context.metadata.any?
291
+
292
+ # Add sanitized parameters
293
+ data[:parameters] = sanitize_parameters(context)
294
+
295
+ data
296
+ end
297
+
298
+ # Determines the status based on context and error type
299
+ #
300
+ # @param context [Context] The execution context
301
+ # @param base_status [String] The base status ("success" or "error")
302
+ # @return [String] The determined status
303
+ def determine_status(context, base_status)
304
+ return base_status if base_status == "success"
305
+
306
+ # Check for timeout errors
307
+ if context.error.is_a?(Timeout::Error)
308
+ "timeout"
309
+ else
310
+ base_status
311
+ end
312
+ end
313
+
314
+ # Sanitizes parameters for storage, redacting sensitive values
315
+ #
316
+ # @param context [Context] The execution context
317
+ # @return [Hash] Sanitized parameters
318
+ def sanitize_parameters(context)
319
+ return {} unless context.agent_instance.respond_to?(:options, true)
320
+
321
+ params = context.agent_instance.send(:options) rescue {}
322
+ params = params.dup
323
+ params.transform_keys!(&:to_s)
324
+
325
+ SENSITIVE_KEYS.each do |key|
326
+ params[key] = "[REDACTED]" if params.key?(key)
327
+ end
328
+
329
+ params
330
+ end
331
+
332
+ # Sensitive parameter keys that should be redacted
333
+ SENSITIVE_KEYS = %w[
334
+ password token api_key secret credential auth key
335
+ access_token refresh_token private_key secret_key
336
+ ].freeze
337
+
338
+ # Truncates error message to prevent database issues
339
+ #
340
+ # @param message [String] The error message
341
+ # @return [String] Truncated message
342
+ def truncate_error_message(message)
343
+ return "" if message.nil?
344
+
345
+ message.to_s.truncate(1000)
346
+ rescue StandardError
347
+ message.to_s[0, 1000]
348
+ end
349
+
350
+ # Queues async logging via background job
351
+ #
352
+ # @param data [Hash] Execution data
353
+ def queue_async_logging(data)
354
+ Infrastructure::ExecutionLoggerJob.perform_later(data)
355
+ end
356
+
357
+ # Creates execution record synchronously
358
+ #
359
+ # @param data [Hash] Execution data
360
+ def create_execution_record(data)
361
+ Execution.create!(data)
362
+ end
363
+
364
+ # Returns whether tracking is enabled for this agent type
365
+ #
366
+ # @param context [Context] The execution context
367
+ # @return [Boolean]
368
+ def tracking_enabled?(context)
369
+ cfg = global_config
370
+
371
+ case context.agent_type
372
+ when :embedding
373
+ cfg.track_embeddings
374
+ when :moderation
375
+ cfg.track_moderation
376
+ when :image
377
+ cfg.track_image_generation
378
+ when :audio
379
+ cfg.track_audio
380
+ else
381
+ cfg.track_executions
382
+ end
383
+ rescue StandardError
384
+ false
385
+ end
386
+
387
+ # Returns whether to track cache hits
388
+ #
389
+ # @return [Boolean]
390
+ def track_cache_hits?
391
+ global_config.respond_to?(:track_cache_hits) && global_config.track_cache_hits
392
+ rescue StandardError
393
+ false
394
+ end
395
+
396
+ # Returns whether async logging is enabled
397
+ #
398
+ # @return [Boolean]
399
+ def async_logging?
400
+ global_config.async_logging && defined?(Infrastructure::ExecutionLoggerJob)
401
+ rescue StandardError
402
+ false
403
+ end
404
+
405
+ # Returns whether the Execution model is available
406
+ #
407
+ # @return [Boolean]
408
+ def execution_model_available?
409
+ defined?(RubyLLM::Agents::Execution)
410
+ end
411
+ end
412
+ end
413
+ end
414
+ end
415
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module Pipeline
6
+ module Middleware
7
+ # Handles retries, fallbacks, and circuit breakers.
8
+ #
9
+ # This middleware provides reliability features for agent executions:
10
+ # - Retries with configurable backoff (constant or exponential)
11
+ # - Model fallbacks when primary model fails
12
+ # - Circuit breaker protection per model
13
+ # - Total timeout across all attempts
14
+ #
15
+ # Reliability is enabled via the agent's reliability DSL:
16
+ # class MyAgent < ApplicationAgent
17
+ # reliability do
18
+ # retries max: 3, backoff: :exponential
19
+ # fallback_models "gpt-4o-mini"
20
+ # total_timeout 30
21
+ # circuit_breaker errors: 5, within: 60
22
+ # end
23
+ # end
24
+ #
25
+ # @example Simple retry
26
+ # class MyEmbedder < RubyLLM::Agents::Embedder
27
+ # model "text-embedding-3-small"
28
+ # reliability do
29
+ # retries max: 2
30
+ # end
31
+ # end
32
+ #
33
+ class Reliability < Base
34
+ # Process with reliability features
35
+ #
36
+ # @param context [Context] The execution context
37
+ # @return [Context] The context after execution
38
+ # @raise [AllModelsFailedError] If all models fail
39
+ # @raise [TotalTimeoutError] If total timeout exceeded
40
+ # @raise [CircuitOpenError] If circuit breaker is open for all models
41
+ def call(context)
42
+ return @app.call(context) unless reliability_enabled?
43
+
44
+ config = reliability_config
45
+ models_to_try = build_models_list(context, config)
46
+ total_deadline = calculate_deadline(config)
47
+
48
+ execute_with_reliability(context, models_to_try, config, total_deadline)
49
+ end
50
+
51
+ private
52
+
53
+ # Returns whether reliability is enabled for this agent
54
+ #
55
+ # @return [Boolean]
56
+ def reliability_enabled?
57
+ @agent_class&.respond_to?(:reliability_config) &&
58
+ @agent_class.reliability_config.present?
59
+ end
60
+
61
+ # Returns the reliability configuration from the agent class
62
+ #
63
+ # @return [Hash] The reliability configuration
64
+ def reliability_config
65
+ @agent_class.reliability_config || {}
66
+ end
67
+
68
+ # Builds the list of models to try
69
+ #
70
+ # @param context [Context] The execution context
71
+ # @param config [Hash] The reliability configuration
72
+ # @return [Array<String>] List of models
73
+ def build_models_list(context, config)
74
+ primary = context.model || @agent_class&.model
75
+ fallbacks = config[:fallback_models] || []
76
+ [primary, *fallbacks].compact.uniq
77
+ end
78
+
79
+ # Calculates the total deadline for all attempts
80
+ #
81
+ # @param config [Hash] The reliability configuration
82
+ # @return [Time, nil] The deadline or nil if no timeout
83
+ def calculate_deadline(config)
84
+ return nil unless config[:total_timeout]
85
+
86
+ Time.current + config[:total_timeout]
87
+ end
88
+
89
+ # Executes with retry, fallback, and circuit breaker logic
90
+ #
91
+ # @param context [Context] The execution context
92
+ # @param models_to_try [Array<String>] List of models to try
93
+ # @param config [Hash] The reliability configuration
94
+ # @param total_deadline [Time, nil] The total deadline
95
+ # @return [Context] The context after execution
96
+ def execute_with_reliability(context, models_to_try, config, total_deadline)
97
+ started_at = Time.current
98
+ last_error = nil
99
+ context.attempts_made = 0
100
+
101
+ models_to_try.each do |current_model|
102
+ # Check circuit breaker for this model
103
+ breaker = get_circuit_breaker(current_model, context)
104
+ if breaker&.open?
105
+ debug("Circuit breaker open for #{current_model}, skipping")
106
+ next
107
+ end
108
+
109
+ result = try_model_with_retries(
110
+ context: context,
111
+ model: current_model,
112
+ config: config,
113
+ total_deadline: total_deadline,
114
+ started_at: started_at,
115
+ breaker: breaker
116
+ )
117
+
118
+ return result if result
119
+
120
+ # Capture the last error from context for the final error
121
+ last_error = context.error
122
+ end
123
+
124
+ # All models exhausted
125
+ raise Agents::Reliability::AllModelsExhaustedError.new(models_to_try, last_error)
126
+ end
127
+
128
+ # Tries a model with retry logic
129
+ #
130
+ # @param context [Context] The execution context
131
+ # @param model [String] The model to try
132
+ # @param config [Hash] The reliability configuration
133
+ # @param total_deadline [Time, nil] The total deadline
134
+ # @param started_at [Time] When execution started
135
+ # @param breaker [CircuitBreaker, nil] The circuit breaker for this model
136
+ # @return [Context, nil] The context if successful, nil to try next model
137
+ def try_model_with_retries(context:, model:, config:, total_deadline:, started_at:, breaker:)
138
+ retries_config = config[:retries] || {}
139
+ max_retries = retries_config[:max] || 0
140
+ attempt_index = 0
141
+
142
+ loop do
143
+ # Check total timeout
144
+ check_total_timeout!(total_deadline, started_at)
145
+
146
+ context.attempt = attempt_index + 1
147
+ context.attempts_made += 1
148
+
149
+ begin
150
+ # Override the model for this attempt
151
+ original_model = context.model
152
+ context.model = model
153
+
154
+ @app.call(context)
155
+
156
+ # Success - record in circuit breaker
157
+ breaker&.record_success!
158
+
159
+ return context
160
+
161
+ rescue StandardError => e
162
+ context.error = e
163
+ breaker&.record_failure!
164
+
165
+ # Check if we should retry
166
+ if should_retry?(e, config, attempt_index, max_retries, total_deadline)
167
+ attempt_index += 1
168
+ delay = calculate_backoff(retries_config, attempt_index)
169
+ async_aware_sleep(delay)
170
+ else
171
+ # Move to next model
172
+ return nil
173
+ end
174
+ ensure
175
+ # Restore original model if we're going to retry or try another model
176
+ context.model = original_model if context.error
177
+ end
178
+ end
179
+ end
180
+
181
+ # Checks if we've exceeded the total timeout
182
+ #
183
+ # @param deadline [Time, nil] The deadline
184
+ # @param started_at [Time] When execution started
185
+ # @raise [TotalTimeoutError] If timeout exceeded
186
+ def check_total_timeout!(deadline, started_at)
187
+ return unless deadline && Time.current > deadline
188
+
189
+ elapsed = Time.current - started_at
190
+ timeout_value = deadline - started_at + elapsed
191
+ raise Agents::Reliability::TotalTimeoutError.new(timeout_value, elapsed)
192
+ end
193
+
194
+ # Determines if we should retry the error
195
+ #
196
+ # @param error [Exception] The error that occurred
197
+ # @param config [Hash] The reliability configuration
198
+ # @param attempt_index [Integer] Current attempt index
199
+ # @param max_retries [Integer] Maximum retries allowed
200
+ # @param total_deadline [Time, nil] The total deadline
201
+ # @return [Boolean] Whether to retry
202
+ def should_retry?(error, config, attempt_index, max_retries, total_deadline)
203
+ return false if attempt_index >= max_retries
204
+ return false if total_deadline && Time.current > total_deadline
205
+
206
+ retryable_error?(error, config)
207
+ end
208
+
209
+ # Checks if an error is retryable
210
+ #
211
+ # @param error [Exception] The error to check
212
+ # @param config [Hash] The reliability configuration
213
+ # @return [Boolean] Whether the error is retryable
214
+ def retryable_error?(error, config)
215
+ custom_errors = config.dig(:retries, :on) || []
216
+ custom_patterns = config[:retryable_patterns]
217
+
218
+ Agents::Reliability.retryable_error?(
219
+ error,
220
+ custom_errors: custom_errors,
221
+ custom_patterns: custom_patterns
222
+ )
223
+ end
224
+
225
+ # Calculates the backoff delay
226
+ #
227
+ # @param retries_config [Hash] The retries configuration
228
+ # @param attempt_index [Integer] The current attempt index
229
+ # @return [Float] The delay in seconds
230
+ def calculate_backoff(retries_config, attempt_index)
231
+ Agents::Reliability.calculate_backoff(
232
+ strategy: retries_config[:backoff] || :exponential,
233
+ base: retries_config[:base] || 0.4,
234
+ max_delay: retries_config[:max_delay] || 3.0,
235
+ attempt: attempt_index
236
+ )
237
+ end
238
+
239
+ # Gets or creates a circuit breaker for a model
240
+ #
241
+ # @param model_id [String] The model identifier
242
+ # @param context [Context] The execution context
243
+ # @return [CircuitBreaker, nil] The circuit breaker or nil
244
+ def get_circuit_breaker(model_id, context)
245
+ cb_config = reliability_config[:circuit_breaker]
246
+ return nil unless cb_config
247
+
248
+ CircuitBreaker.from_config(
249
+ @agent_class&.name,
250
+ model_id,
251
+ cb_config,
252
+ tenant_id: context.tenant_id
253
+ )
254
+ end
255
+
256
+ # Sleeps without blocking other fibers when in async context
257
+ #
258
+ # @param seconds [Numeric] Duration to sleep
259
+ # @return [void]
260
+ def async_aware_sleep(seconds)
261
+ config = global_config
262
+
263
+ if config.respond_to?(:async_context?) && config.async_context?
264
+ ::Async::Task.current.sleep(seconds)
265
+ else
266
+ sleep(seconds)
267
+ end
268
+ rescue StandardError
269
+ # Fall back to regular sleep if async detection fails
270
+ sleep(seconds)
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end