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,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module Pipeline
6
+ module Middleware
7
+ # Base class for all middleware in the pipeline.
8
+ #
9
+ # Middleware wraps the next handler in the chain and can:
10
+ # - Modify the context before passing it down
11
+ # - Short-circuit the chain (e.g., return cached result)
12
+ # - Handle errors from downstream
13
+ # - Modify the context after the response
14
+ #
15
+ # Each middleware receives:
16
+ # - @app: The next handler in the chain (another middleware or the executor)
17
+ # - @agent_class: The agent class, for reading DSL configuration
18
+ #
19
+ # @example Simple pass-through middleware
20
+ # class Logger < Base
21
+ # def call(context)
22
+ # puts "Before: #{context.input}"
23
+ # result = @app.call(context)
24
+ # puts "After: #{context.output}"
25
+ # result
26
+ # end
27
+ # end
28
+ #
29
+ # @example Short-circuiting middleware
30
+ # class Cache < Base
31
+ # def call(context)
32
+ # if (cached = read_cache(context))
33
+ # context.output = cached
34
+ # context.cached = true
35
+ # return context
36
+ # end
37
+ # @app.call(context)
38
+ # end
39
+ # end
40
+ #
41
+ # @abstract Subclass and implement {#call}
42
+ #
43
+ class Base
44
+ # @param app [#call] The next handler in the chain
45
+ # @param agent_class [Class] The agent class (for reading DSL config)
46
+ def initialize(app, agent_class)
47
+ @app = app
48
+ @agent_class = agent_class
49
+ end
50
+
51
+ # Process the context through this middleware
52
+ #
53
+ # Subclasses must implement this method. The typical pattern is:
54
+ # 1. Do pre-processing on context
55
+ # 2. Call @app.call(context) to continue the chain
56
+ # 3. Do post-processing on context
57
+ # 4. Return context
58
+ #
59
+ # @param context [Context] The execution context
60
+ # @return [Context] The (possibly modified) context
61
+ # @raise [NotImplementedError] If not implemented by subclass
62
+ def call(context)
63
+ raise NotImplementedError, "#{self.class} must implement #call"
64
+ end
65
+
66
+ private
67
+
68
+ # Read configuration from agent class DSL
69
+ #
70
+ # Safely reads a DSL method value from the agent class,
71
+ # returning a default if the method doesn't exist.
72
+ #
73
+ # @param method [Symbol] DSL method name
74
+ # @param default [Object] Default value if not set
75
+ # @return [Object] The configuration value
76
+ def config(method, default = nil)
77
+ return default unless @agent_class
78
+
79
+ if @agent_class.respond_to?(method)
80
+ @agent_class.send(method)
81
+ else
82
+ default
83
+ end
84
+ end
85
+
86
+ # Check if a DSL option is enabled
87
+ #
88
+ # Convenience method for boolean DSL options.
89
+ #
90
+ # @param method [Symbol] DSL method name (e.g., :cache_enabled?)
91
+ # @return [Boolean]
92
+ def enabled?(method)
93
+ config(method, false) == true
94
+ end
95
+
96
+ # Returns the global configuration
97
+ #
98
+ # @return [Configuration] The RubyLLM::Agents configuration
99
+ def global_config
100
+ RubyLLM::Agents.configuration
101
+ end
102
+
103
+ # Log a debug message if Rails logger is available
104
+ #
105
+ # @param message [String] The message to log
106
+ def debug(message)
107
+ return unless defined?(Rails) && Rails.logger
108
+
109
+ Rails.logger.debug("[RubyLLM::Agents::Pipeline] #{message}")
110
+ end
111
+
112
+ # Log an error message if Rails logger is available
113
+ #
114
+ # @param message [String] The message to log
115
+ def error(message)
116
+ return unless defined?(Rails) && Rails.logger
117
+
118
+ Rails.logger.error("[RubyLLM::Agents::Pipeline] #{message}")
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module Pipeline
6
+ module Middleware
7
+ # Checks budget limits before execution and records spend after.
8
+ #
9
+ # This middleware integrates with the BudgetTracker to:
10
+ # - Check if budget limits are exceeded before execution
11
+ # - Record spend after successful execution
12
+ #
13
+ # Budget checking is skipped if:
14
+ # - Budgets are disabled globally in configuration
15
+ # - The result was served from cache (no API call was made)
16
+ #
17
+ # @example With budget enforcement
18
+ # # In config/initializers/ruby_llm_agents.rb
19
+ # RubyLLM::Agents.configure do |config|
20
+ # config.budgets_enabled = true
21
+ # end
22
+ #
23
+ # # Budget will be checked before execution
24
+ # MyAgent.call(query: "test", tenant: { id: "org_123" })
25
+ #
26
+ class Budget < Base
27
+ # Process budget checking and spend recording
28
+ #
29
+ # @param context [Context] The execution context
30
+ # @return [Context] The context after budget processing
31
+ # @raise [BudgetExceededError] If budget is exceeded with hard enforcement
32
+ def call(context)
33
+ return @app.call(context) unless budgets_enabled?
34
+
35
+ # Check budget before execution
36
+ check_budget!(context)
37
+
38
+ # Execute the chain
39
+ @app.call(context)
40
+
41
+ # Record spend after successful execution (if not cached)
42
+ record_spend!(context) if context.success? && !context.cached?
43
+
44
+ context
45
+ end
46
+
47
+ private
48
+
49
+ # Returns whether budgets are enabled globally
50
+ #
51
+ # @return [Boolean]
52
+ def budgets_enabled?
53
+ global_config.budgets_enabled?
54
+ rescue StandardError
55
+ false
56
+ end
57
+
58
+ # Checks budget before execution
59
+ #
60
+ # @param context [Context] The execution context
61
+ # @raise [BudgetExceededError] If budget exceeded with hard enforcement
62
+ def check_budget!(context)
63
+ BudgetTracker.check!(
64
+ agent_type: context.agent_class&.name,
65
+ tenant_id: context.tenant_id,
66
+ execution_type: context.agent_type&.to_s
67
+ )
68
+ rescue BudgetExceededError
69
+ # Re-raise budget errors
70
+ raise
71
+ rescue StandardError => e
72
+ # Log but don't fail on budget check errors
73
+ error("Budget check failed: #{e.message}")
74
+ end
75
+
76
+ # Records spend after execution
77
+ #
78
+ # @param context [Context] The execution context
79
+ def record_spend!(context)
80
+ return unless context.total_cost&.positive?
81
+
82
+ BudgetTracker.record_spend!(
83
+ tenant_id: context.tenant_id,
84
+ cost: context.total_cost,
85
+ tokens: context.total_tokens
86
+ )
87
+ rescue StandardError => e
88
+ # Log but don't fail on spend recording errors
89
+ error("Failed to record spend: #{e.message}")
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module RubyLLM
6
+ module Agents
7
+ module Pipeline
8
+ module Middleware
9
+ # Caches results to avoid redundant API calls.
10
+ #
11
+ # This middleware provides caching for agent executions:
12
+ # - Checks cache before execution
13
+ # - Stores successful results in cache
14
+ # - Respects TTL configuration from agent DSL
15
+ #
16
+ # Caching is skipped if:
17
+ # - Caching is not enabled on the agent class (no cache_for DSL)
18
+ # - The cache store is not configured
19
+ #
20
+ # @example Enable caching on an agent
21
+ # class MyEmbedder < RubyLLM::Agents::Embedder
22
+ # model "text-embedding-3-small"
23
+ # cache_for 1.hour
24
+ # end
25
+ #
26
+ # @example Cache versioning
27
+ # class MyEmbedder < RubyLLM::Agents::Embedder
28
+ # model "text-embedding-3-small"
29
+ # version "2.0" # Change to invalidate cache
30
+ # cache_for 1.hour
31
+ # end
32
+ #
33
+ class Cache < Base
34
+ # Process caching
35
+ #
36
+ # @param context [Context] The execution context
37
+ # @return [Context] The context (possibly from cache)
38
+ def call(context)
39
+ return @app.call(context) unless cache_enabled?
40
+
41
+ cache_key = generate_cache_key(context)
42
+
43
+ # Skip cache read if skip_cache is true
44
+ unless context.skip_cache
45
+ # Try to read from cache
46
+ if (cached = cache_read(cache_key))
47
+ context.output = cached
48
+ context.cached = true
49
+ context[:cache_key] = cache_key
50
+ debug("Cache hit for #{cache_key}")
51
+ return context
52
+ end
53
+ end
54
+
55
+ # Execute the chain
56
+ @app.call(context)
57
+
58
+ # Cache successful results
59
+ if context.success?
60
+ cache_write(cache_key, context.output)
61
+ debug("Cache write for #{cache_key}")
62
+ end
63
+
64
+ context
65
+ end
66
+
67
+ private
68
+
69
+ # Returns whether caching is enabled for this agent
70
+ #
71
+ # @return [Boolean]
72
+ def cache_enabled?
73
+ enabled?(:cache_enabled?) && cache_store.present?
74
+ end
75
+
76
+ # Returns the cache store
77
+ #
78
+ # @return [ActiveSupport::Cache::Store, nil]
79
+ def cache_store
80
+ global_config.cache_store
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ # Returns the cache TTL
86
+ #
87
+ # @return [ActiveSupport::Duration, Integer, nil]
88
+ def cache_ttl
89
+ config(:cache_ttl)
90
+ end
91
+
92
+ # Generates a cache key for the context
93
+ #
94
+ # The cache key includes:
95
+ # - Namespace prefix
96
+ # - Agent type
97
+ # - Agent class name
98
+ # - Version (for cache invalidation)
99
+ # - Model
100
+ # - SHA256 hash of input
101
+ #
102
+ # @param context [Context] The execution context
103
+ # @return [String] The cache key
104
+ def generate_cache_key(context)
105
+ components = [
106
+ "ruby_llm_agents",
107
+ context.agent_type,
108
+ context.agent_class&.name,
109
+ config(:version, "1.0"),
110
+ context.model,
111
+ hash_input(context.input)
112
+ ].compact
113
+
114
+ components.join("/")
115
+ end
116
+
117
+ # Hashes the input for cache key
118
+ #
119
+ # @param input [Object] The input to hash
120
+ # @return [String] SHA256 hash
121
+ def hash_input(input)
122
+ Digest::SHA256.hexdigest(serialize_input(input))
123
+ end
124
+
125
+ # Serializes input for hashing
126
+ #
127
+ # @param input [Object] The input to serialize
128
+ # @return [String] Serialized representation
129
+ def serialize_input(input)
130
+ case input
131
+ when String
132
+ input
133
+ when Array
134
+ input.map { |i| serialize_input(i) }.join("|")
135
+ when Hash
136
+ input.sort.map { |k, v| "#{k}:#{serialize_input(v)}" }.join("|")
137
+ else
138
+ input.to_json
139
+ end
140
+ rescue StandardError
141
+ input.to_s
142
+ end
143
+
144
+ # Reads from cache
145
+ #
146
+ # @param key [String] Cache key
147
+ # @return [Object, nil] Cached value or nil
148
+ def cache_read(key)
149
+ cache_store.read(key)
150
+ rescue StandardError => e
151
+ error("Cache read failed: #{e.message}")
152
+ nil
153
+ end
154
+
155
+ # Writes to cache
156
+ #
157
+ # @param key [String] Cache key
158
+ # @param value [Object] Value to cache
159
+ def cache_write(key, value)
160
+ options = {}
161
+ options[:expires_in] = cache_ttl if cache_ttl
162
+
163
+ cache_store.write(key, value, **options)
164
+ rescue StandardError => e
165
+ error("Cache write failed: #{e.message}")
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end