ruby_llm-agents 0.4.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 (208) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +225 -34
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +136 -16
  4. data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +214 -0
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +29 -9
  6. data/app/controllers/ruby_llm/agents/{settings_controller.rb → system_config_controller.rb} +3 -3
  7. data/app/controllers/ruby_llm/agents/tenants_controller.rb +109 -0
  8. data/app/controllers/ruby_llm/agents/workflows_controller.rb +355 -0
  9. data/app/helpers/ruby_llm/agents/application_helper.rb +25 -0
  10. data/app/models/ruby_llm/agents/api_configuration.rb +386 -0
  11. data/app/models/ruby_llm/agents/execution.rb +3 -0
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +112 -14
  13. data/app/services/ruby_llm/agents/agent_registry.rb +51 -12
  14. data/app/views/layouts/ruby_llm/agents/application.html.erb +5 -30
  15. data/app/views/ruby_llm/agents/agents/_agent.html.erb +13 -1
  16. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +235 -0
  17. data/app/views/ruby_llm/agents/agents/_config_embedder.html.erb +70 -0
  18. data/app/views/ruby_llm/agents/agents/_config_image_generator.html.erb +152 -0
  19. data/app/views/ruby_llm/agents/agents/_config_moderator.html.erb +63 -0
  20. data/app/views/ruby_llm/agents/agents/_config_speaker.html.erb +108 -0
  21. data/app/views/ruby_llm/agents/agents/_config_transcriber.html.erb +91 -0
  22. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +1 -1
  23. data/app/views/ruby_llm/agents/agents/index.html.erb +74 -9
  24. data/app/views/ruby_llm/agents/agents/show.html.erb +18 -378
  25. data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +34 -0
  26. data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +288 -0
  27. data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +95 -0
  28. data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +97 -0
  29. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +211 -0
  30. data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +179 -0
  31. data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +1 -1
  32. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +269 -15
  33. data/app/views/ruby_llm/agents/executions/show.html.erb +98 -0
  34. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +93 -0
  35. data/app/views/ruby_llm/agents/{settings → system_config}/show.html.erb +1 -1
  36. data/app/views/ruby_llm/agents/tenants/_form.html.erb +150 -0
  37. data/app/views/ruby_llm/agents/tenants/edit.html.erb +13 -0
  38. data/app/views/ruby_llm/agents/tenants/index.html.erb +129 -0
  39. data/app/views/ruby_llm/agents/tenants/show.html.erb +374 -0
  40. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +236 -0
  41. data/app/views/ruby_llm/agents/workflows/_structure_parallel.html.erb +76 -0
  42. data/app/views/ruby_llm/agents/workflows/_structure_pipeline.html.erb +74 -0
  43. data/app/views/ruby_llm/agents/workflows/_structure_router.html.erb +108 -0
  44. data/app/views/ruby_llm/agents/workflows/show.html.erb +442 -0
  45. data/config/routes.rb +13 -1
  46. data/lib/generators/ruby_llm_agents/agent_generator.rb +56 -7
  47. data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +100 -0
  48. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +110 -0
  49. data/lib/generators/ruby_llm_agents/embedder_generator.rb +107 -0
  50. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +115 -0
  51. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +108 -0
  52. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +116 -0
  53. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +178 -0
  54. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +109 -0
  55. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +103 -0
  56. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +102 -0
  57. data/lib/generators/ruby_llm_agents/install_generator.rb +76 -4
  58. data/lib/generators/ruby_llm_agents/restructure_generator.rb +292 -0
  59. data/lib/generators/ruby_llm_agents/speaker_generator.rb +121 -0
  60. data/lib/generators/ruby_llm_agents/templates/add_execution_type_migration.rb.tt +8 -0
  61. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +99 -84
  62. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +42 -40
  63. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +26 -0
  64. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +50 -0
  65. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +26 -0
  66. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +20 -0
  67. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +38 -0
  68. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +139 -0
  69. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +21 -0
  70. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +20 -0
  71. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +20 -0
  72. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +49 -0
  73. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +53 -0
  74. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +44 -0
  75. data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +90 -0
  76. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +41 -0
  77. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +45 -0
  78. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +35 -0
  79. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +47 -0
  80. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +50 -0
  81. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +44 -0
  82. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +38 -0
  83. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +33 -0
  84. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +228 -0
  85. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +131 -0
  86. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +255 -0
  87. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +120 -0
  88. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +102 -0
  89. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +282 -0
  90. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +228 -0
  91. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +120 -0
  92. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +110 -0
  93. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +120 -0
  94. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +212 -0
  95. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +227 -0
  96. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +251 -0
  97. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +300 -0
  98. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +56 -0
  99. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +51 -0
  100. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +107 -0
  101. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +152 -1
  102. data/lib/ruby_llm/agents/audio/speaker.rb +553 -0
  103. data/lib/ruby_llm/agents/audio/transcriber.rb +669 -0
  104. data/lib/ruby_llm/agents/base_agent.rb +675 -0
  105. data/lib/ruby_llm/agents/core/base/moderation_dsl.rb +181 -0
  106. data/lib/ruby_llm/agents/core/base/moderation_execution.rb +274 -0
  107. data/lib/ruby_llm/agents/core/base.rb +135 -0
  108. data/lib/ruby_llm/agents/core/configuration.rb +981 -0
  109. data/lib/ruby_llm/agents/core/errors.rb +150 -0
  110. data/lib/ruby_llm/agents/{instrumentation.rb → core/instrumentation.rb} +93 -4
  111. data/lib/ruby_llm/agents/core/llm_tenant.rb +358 -0
  112. data/lib/ruby_llm/agents/core/resolved_config.rb +348 -0
  113. data/lib/ruby_llm/agents/{version.rb → core/version.rb} +1 -1
  114. data/lib/ruby_llm/agents/dsl/base.rb +110 -0
  115. data/lib/ruby_llm/agents/dsl/caching.rb +142 -0
  116. data/lib/ruby_llm/agents/dsl/reliability.rb +307 -0
  117. data/lib/ruby_llm/agents/dsl.rb +41 -0
  118. data/lib/ruby_llm/agents/image/analyzer/dsl.rb +130 -0
  119. data/lib/ruby_llm/agents/image/analyzer/execution.rb +402 -0
  120. data/lib/ruby_llm/agents/image/analyzer.rb +90 -0
  121. data/lib/ruby_llm/agents/image/background_remover/dsl.rb +154 -0
  122. data/lib/ruby_llm/agents/image/background_remover/execution.rb +240 -0
  123. data/lib/ruby_llm/agents/image/background_remover.rb +89 -0
  124. data/lib/ruby_llm/agents/image/concerns/image_operation_dsl.rb +91 -0
  125. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +165 -0
  126. data/lib/ruby_llm/agents/image/editor/dsl.rb +56 -0
  127. data/lib/ruby_llm/agents/image/editor/execution.rb +207 -0
  128. data/lib/ruby_llm/agents/image/editor.rb +92 -0
  129. data/lib/ruby_llm/agents/image/generator/active_storage_support.rb +127 -0
  130. data/lib/ruby_llm/agents/image/generator/content_policy.rb +95 -0
  131. data/lib/ruby_llm/agents/image/generator/pricing.rb +353 -0
  132. data/lib/ruby_llm/agents/image/generator/templates.rb +124 -0
  133. data/lib/ruby_llm/agents/image/generator.rb +455 -0
  134. data/lib/ruby_llm/agents/image/pipeline/dsl.rb +213 -0
  135. data/lib/ruby_llm/agents/image/pipeline/execution.rb +382 -0
  136. data/lib/ruby_llm/agents/image/pipeline.rb +97 -0
  137. data/lib/ruby_llm/agents/image/transformer/dsl.rb +148 -0
  138. data/lib/ruby_llm/agents/image/transformer/execution.rb +223 -0
  139. data/lib/ruby_llm/agents/image/transformer.rb +95 -0
  140. data/lib/ruby_llm/agents/image/upscaler/dsl.rb +83 -0
  141. data/lib/ruby_llm/agents/image/upscaler/execution.rb +219 -0
  142. data/lib/ruby_llm/agents/image/upscaler.rb +81 -0
  143. data/lib/ruby_llm/agents/image/variator/dsl.rb +62 -0
  144. data/lib/ruby_llm/agents/image/variator/execution.rb +189 -0
  145. data/lib/ruby_llm/agents/image/variator.rb +80 -0
  146. data/lib/ruby_llm/agents/{alert_manager.rb → infrastructure/alert_manager.rb} +17 -22
  147. data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +145 -0
  148. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +149 -0
  149. data/lib/ruby_llm/agents/infrastructure/budget/forecaster.rb +68 -0
  150. data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +279 -0
  151. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +275 -0
  152. data/lib/ruby_llm/agents/{execution_logger_job.rb → infrastructure/execution_logger_job.rb} +17 -1
  153. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/executor.rb +2 -1
  154. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/retry_strategy.rb +9 -3
  155. data/lib/ruby_llm/agents/{reliability.rb → infrastructure/reliability.rb} +11 -21
  156. data/lib/ruby_llm/agents/pipeline/builder.rb +215 -0
  157. data/lib/ruby_llm/agents/pipeline/context.rb +255 -0
  158. data/lib/ruby_llm/agents/pipeline/executor.rb +86 -0
  159. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +124 -0
  160. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +95 -0
  161. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +171 -0
  162. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +415 -0
  163. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +276 -0
  164. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +196 -0
  165. data/lib/ruby_llm/agents/pipeline.rb +68 -0
  166. data/lib/ruby_llm/agents/{engine.rb → rails/engine.rb} +79 -10
  167. data/lib/ruby_llm/agents/results/background_removal_result.rb +286 -0
  168. data/lib/ruby_llm/agents/{result.rb → results/base.rb} +73 -1
  169. data/lib/ruby_llm/agents/results/embedding_result.rb +243 -0
  170. data/lib/ruby_llm/agents/results/image_analysis_result.rb +314 -0
  171. data/lib/ruby_llm/agents/results/image_edit_result.rb +250 -0
  172. data/lib/ruby_llm/agents/results/image_generation_result.rb +346 -0
  173. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +399 -0
  174. data/lib/ruby_llm/agents/results/image_transform_result.rb +251 -0
  175. data/lib/ruby_llm/agents/results/image_upscale_result.rb +255 -0
  176. data/lib/ruby_llm/agents/results/image_variation_result.rb +237 -0
  177. data/lib/ruby_llm/agents/results/moderation_result.rb +158 -0
  178. data/lib/ruby_llm/agents/results/speech_result.rb +338 -0
  179. data/lib/ruby_llm/agents/results/transcription_result.rb +408 -0
  180. data/lib/ruby_llm/agents/text/embedder.rb +444 -0
  181. data/lib/ruby_llm/agents/text/moderator.rb +237 -0
  182. data/lib/ruby_llm/agents/workflow/async.rb +220 -0
  183. data/lib/ruby_llm/agents/workflow/async_executor.rb +156 -0
  184. data/lib/ruby_llm/agents/{workflow.rb → workflow/orchestrator.rb} +6 -5
  185. data/lib/ruby_llm/agents/workflow/parallel.rb +34 -17
  186. data/lib/ruby_llm/agents/workflow/thread_pool.rb +185 -0
  187. data/lib/ruby_llm/agents.rb +86 -20
  188. metadata +189 -35
  189. data/lib/ruby_llm/agents/base/caching.rb +0 -40
  190. data/lib/ruby_llm/agents/base/cost_calculation.rb +0 -105
  191. data/lib/ruby_llm/agents/base/dsl.rb +0 -324
  192. data/lib/ruby_llm/agents/base/execution.rb +0 -283
  193. data/lib/ruby_llm/agents/base/reliability_dsl.rb +0 -82
  194. data/lib/ruby_llm/agents/base/reliability_execution.rb +0 -136
  195. data/lib/ruby_llm/agents/base/response_building.rb +0 -86
  196. data/lib/ruby_llm/agents/base/tool_tracking.rb +0 -57
  197. data/lib/ruby_llm/agents/base.rb +0 -209
  198. data/lib/ruby_llm/agents/budget_tracker.rb +0 -471
  199. data/lib/ruby_llm/agents/configuration.rb +0 -357
  200. /data/lib/ruby_llm/agents/{deprecations.rb → core/deprecations.rb} +0 -0
  201. /data/lib/ruby_llm/agents/{inflections.rb → core/inflections.rb} +0 -0
  202. /data/lib/ruby_llm/agents/{attempt_tracker.rb → infrastructure/attempt_tracker.rb} +0 -0
  203. /data/lib/ruby_llm/agents/{cache_helper.rb → infrastructure/cache_helper.rb} +0 -0
  204. /data/lib/ruby_llm/agents/{circuit_breaker.rb → infrastructure/circuit_breaker.rb} +0 -0
  205. /data/lib/ruby_llm/agents/{redactor.rb → infrastructure/redactor.rb} +0 -0
  206. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/breaker_manager.rb +0 -0
  207. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/execution_constraints.rb +0 -0
  208. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/fallback_routing.rb +0 -0
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Base error class for RubyLLM::Agents
6
+ class Error < StandardError; end
7
+
8
+ # ============================================================
9
+ # Pipeline Errors
10
+ # ============================================================
11
+
12
+ # Base class for pipeline-related errors
13
+ class PipelineError < Error; end
14
+
15
+ # ============================================================
16
+ # Reliability Errors
17
+ # ============================================================
18
+
19
+ # Base class for reliability-related errors
20
+ class ReliabilityError < Error; end
21
+
22
+ # Raised when an error is retryable (transient)
23
+ class RetryableError < ReliabilityError; end
24
+
25
+ # Raised when a circuit breaker is open
26
+ class CircuitOpenError < ReliabilityError
27
+ # @return [String] The model that has an open circuit
28
+ attr_reader :model
29
+
30
+ def initialize(message = nil, model: nil)
31
+ @model = model
32
+ super(message || "Circuit breaker is open#{model ? " for #{model}" : ""}")
33
+ end
34
+ end
35
+
36
+ # Raised when total timeout is exceeded across all attempts
37
+ class TotalTimeoutError < ReliabilityError
38
+ # @return [Float] The timeout that was exceeded
39
+ attr_reader :timeout
40
+
41
+ # @return [Float] The elapsed time
42
+ attr_reader :elapsed
43
+
44
+ def initialize(message = nil, timeout: nil, elapsed: nil)
45
+ @timeout = timeout
46
+ @elapsed = elapsed
47
+ super(message || "Total timeout of #{timeout}s exceeded (elapsed: #{elapsed&.round(2)}s)")
48
+ end
49
+ end
50
+
51
+ # Raised when all models (primary + fallbacks) fail
52
+ class AllModelsFailedError < ReliabilityError
53
+ # @return [Array<Hash>] Details of each failed attempt
54
+ attr_reader :attempts
55
+
56
+ def initialize(message = nil, attempts: [])
57
+ @attempts = attempts
58
+ models = attempts.map { |a| a[:model] }.compact.join(", ")
59
+ super(message || "All models failed: #{models}")
60
+ end
61
+ end
62
+
63
+ # ============================================================
64
+ # Budget Errors
65
+ # ============================================================
66
+
67
+ # Base class for budget-related errors
68
+ class BudgetError < Error; end
69
+
70
+ # Raised when budget is exceeded
71
+ class BudgetExceededError < BudgetError
72
+ # @return [String, nil] The tenant ID
73
+ attr_reader :tenant_id
74
+
75
+ # @return [String, nil] The budget type (daily, monthly, etc.)
76
+ attr_reader :budget_type
77
+
78
+ def initialize(message = nil, tenant_id: nil, budget_type: nil)
79
+ @tenant_id = tenant_id
80
+ @budget_type = budget_type
81
+ super(message || "Budget exceeded#{tenant_id ? " for tenant #{tenant_id}" : ""}")
82
+ end
83
+ end
84
+
85
+ # ============================================================
86
+ # Configuration Errors
87
+ # ============================================================
88
+
89
+ # Raised for configuration issues
90
+ class ConfigurationError < Error; end
91
+
92
+ # Raised when content is flagged during moderation
93
+ #
94
+ # Contains the full moderation result and the phase where
95
+ # the content was flagged.
96
+ #
97
+ # @example Handling moderation errors
98
+ # begin
99
+ # result = MyAgent.call(message: user_input)
100
+ # rescue RubyLLM::Agents::ModerationError => e
101
+ # puts "Content blocked: #{e.flagged_categories.join(', ')}"
102
+ # puts "Phase: #{e.phase}"
103
+ # puts "Scores: #{e.category_scores}"
104
+ # end
105
+ #
106
+ # @api public
107
+ class ModerationError < Error
108
+ # @return [Object] The raw moderation result from RubyLLM
109
+ attr_reader :moderation_result
110
+
111
+ # @return [Symbol] The phase where content was flagged (:input or :output)
112
+ attr_reader :phase
113
+
114
+ # Creates a new ModerationError
115
+ #
116
+ # @param moderation_result [Object] The moderation result from RubyLLM
117
+ # @param phase [Symbol] The phase where content was flagged
118
+ def initialize(moderation_result, phase)
119
+ @moderation_result = moderation_result
120
+ @phase = phase
121
+
122
+ categories = moderation_result.flagged_categories
123
+ category_list = categories.respond_to?(:join) ? categories.join(", ") : categories.to_s
124
+
125
+ super("Content flagged during #{phase} moderation: #{category_list}")
126
+ end
127
+
128
+ # Returns the flagged categories from the moderation result
129
+ #
130
+ # @return [Array<String, Symbol>] List of flagged categories
131
+ def flagged_categories
132
+ moderation_result.flagged_categories
133
+ end
134
+
135
+ # Returns the category scores from the moderation result
136
+ #
137
+ # @return [Hash{String, Symbol => Float}] Category to score mapping
138
+ def category_scores
139
+ moderation_result.category_scores
140
+ end
141
+
142
+ # Returns whether the moderation result was flagged
143
+ #
144
+ # @return [Boolean] Always true for ModerationError
145
+ def flagged?
146
+ true
147
+ end
148
+ end
149
+ end
150
+ end
@@ -245,7 +245,9 @@ module RubyLLM
245
245
  metadata: metadata,
246
246
  system_prompt: config.persist_prompts ? redacted_system_prompt : nil,
247
247
  user_prompt: config.persist_prompts ? redacted_user_prompt : nil,
248
- streaming: self.class.streaming
248
+ streaming: self.class.streaming,
249
+ messages_count: resolved_messages.size,
250
+ messages_summary: config.persist_messages_summary ? messages_summary : {}
249
251
  }
250
252
 
251
253
  # Extract tracing fields from metadata if present
@@ -326,6 +328,9 @@ module RubyLLM
326
328
  Rails.logger.warn("[RubyLLM::Agents] Cost calculation failed: #{cost_error.message}")
327
329
  end
328
330
  end
331
+
332
+ # Record token usage for budget tracking
333
+ record_token_usage(execution)
329
334
  rescue ActiveRecord::RecordInvalid => e
330
335
  Rails.logger.error("[RubyLLM::Agents] Validation failed for execution #{execution&.id}: #{e.record.errors.full_messages.join(', ')}")
331
336
  if Rails.env.development? || Rails.env.test?
@@ -415,6 +420,9 @@ module RubyLLM
415
420
  Rails.logger.warn("[RubyLLM::Agents] Cost calculation failed: #{cost_error.message}")
416
421
  end
417
422
  end
423
+
424
+ # Record token usage for budget tracking
425
+ record_token_usage(execution)
418
426
  rescue ActiveRecord::RecordInvalid => e
419
427
  Rails.logger.error("[RubyLLM::Agents] Validation failed for execution #{execution&.id}: #{e.record.errors.full_messages.join(', ')}")
420
428
  if Rails.env.development? || Rails.env.test?
@@ -440,6 +448,8 @@ module RubyLLM
440
448
  # @param error [Exception, nil] The exception if failed
441
449
  # @return [void]
442
450
  def legacy_log_execution(completed_at:, status:, response: nil, error: nil)
451
+ config = RubyLLM::Agents.configuration
452
+
443
453
  execution_data = {
444
454
  agent_type: self.class.name,
445
455
  agent_version: self.class.version,
@@ -452,7 +462,9 @@ module RubyLLM
452
462
  parameters: sanitized_parameters,
453
463
  metadata: execution_metadata,
454
464
  system_prompt: safe_system_prompt,
455
- user_prompt: safe_user_prompt
465
+ user_prompt: safe_user_prompt,
466
+ messages_count: resolved_messages.size,
467
+ messages_summary: config.persist_messages_summary ? messages_summary : {}
456
468
  }
457
469
 
458
470
  # Add response data if available (using safe extraction)
@@ -516,6 +528,38 @@ module RubyLLM
516
528
  Redactor.redact_string(prompt)
517
529
  end
518
530
 
531
+ # Returns a summary of messages (first and last, truncated)
532
+ #
533
+ # Creates a summary of the conversation messages containing the first
534
+ # and last messages (if different) with content truncated for storage.
535
+ #
536
+ # @return [Hash] Summary with :first and :last message hashes, or empty hash
537
+ def messages_summary
538
+ msgs = resolved_messages
539
+ return {} if msgs.blank?
540
+
541
+ max_len = RubyLLM::Agents.configuration.messages_summary_max_length || 500
542
+
543
+ summary = {}
544
+
545
+ if msgs.first
546
+ summary[:first] = {
547
+ role: msgs.first[:role].to_s,
548
+ content: Redactor.redact_string(msgs.first[:content].to_s).truncate(max_len)
549
+ }
550
+ end
551
+
552
+ # Only add last if there are multiple messages and last is different from first
553
+ if msgs.size > 1 && msgs.last
554
+ summary[:last] = {
555
+ role: msgs.last[:role].to_s,
556
+ content: Redactor.redact_string(msgs.last[:content].to_s).truncate(max_len)
557
+ }
558
+ end
559
+
560
+ summary
561
+ end
562
+
519
563
  # Returns the response with redaction applied
520
564
  #
521
565
  # @param response [RubyLLM::Message] The LLM response
@@ -583,6 +627,9 @@ module RubyLLM
583
627
  # during multi-turn conversations (when tools are used)
584
628
  tool_calls_data = respond_to?(:accumulated_tool_calls) ? accumulated_tool_calls : []
585
629
 
630
+ # Extract thinking data if present
631
+ thinking_data = safe_extract_thinking_data(response)
632
+
586
633
  {
587
634
  input_tokens: safe_response_value(response, :input_tokens),
588
635
  output_tokens: safe_response_value(response, :output_tokens),
@@ -593,7 +640,7 @@ module RubyLLM
593
640
  response: safe_serialize_response(response),
594
641
  tool_calls: tool_calls_data || [],
595
642
  tool_calls_count: tool_calls_data&.size || 0
596
- }.compact
643
+ }.merge(thinking_data).compact
597
644
  end
598
645
 
599
646
  # Extracts finish reason from response, normalizing to standard values
@@ -621,6 +668,24 @@ module RubyLLM
621
668
  end
622
669
  end
623
670
 
671
+ # Extracts thinking data from response
672
+ #
673
+ # Handles different response structures from various providers.
674
+ # The thinking object typically has text, signature, and tokens.
675
+ #
676
+ # @param response [RubyLLM::Message] The LLM response
677
+ # @return [Hash] Thinking data (empty if none present)
678
+ def safe_extract_thinking_data(response)
679
+ thinking = safe_response_value(response, :thinking)
680
+ return {} unless thinking
681
+
682
+ {
683
+ thinking_text: thinking.respond_to?(:text) ? thinking.text : thinking[:text],
684
+ thinking_signature: thinking.respond_to?(:signature) ? thinking.signature : thinking[:signature],
685
+ thinking_tokens: thinking.respond_to?(:tokens) ? thinking.tokens : thinking[:tokens]
686
+ }.compact
687
+ end
688
+
624
689
  # Extracts routing/retry tracking data from attempt tracker
625
690
  #
626
691
  # Analyzes the execution attempts to determine:
@@ -773,7 +838,9 @@ module RubyLLM
773
838
  total_cost: 0,
774
839
  parameters: redacted_parameters,
775
840
  metadata: execution_metadata,
776
- streaming: self.class.streaming
841
+ streaming: self.class.streaming,
842
+ messages_count: resolved_messages.size,
843
+ messages_summary: config.persist_messages_summary ? messages_summary : {}
777
844
  }
778
845
 
779
846
  # Add tracing fields from metadata if present
@@ -798,6 +865,28 @@ module RubyLLM
798
865
  Rails.logger.error("[RubyLLM::Agents] Failed to record cache hit execution: #{e.message}")
799
866
  end
800
867
 
868
+ # Records token usage to the BudgetTracker
869
+ #
870
+ # @param execution [Execution] The completed execution record
871
+ # @return [void]
872
+ def record_token_usage(execution)
873
+ return unless execution&.total_tokens && execution.total_tokens > 0
874
+
875
+ begin
876
+ tenant_id = respond_to?(:resolved_tenant_id) ? resolved_tenant_id : nil
877
+ tenant_config = respond_to?(:runtime_tenant_config) ? runtime_tenant_config : nil
878
+
879
+ BudgetTracker.record_tokens!(
880
+ self.class.name,
881
+ execution.total_tokens,
882
+ tenant_id: tenant_id,
883
+ tenant_config: tenant_config
884
+ )
885
+ rescue StandardError => e
886
+ Rails.logger.warn("[RubyLLM::Agents] Failed to record token usage: #{e.message}")
887
+ end
888
+ end
889
+
801
890
  # Emergency fallback to mark execution as failed
802
891
  #
803
892
  # Uses update_all to bypass ActiveRecord callbacks and validations,
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module RubyLLM
6
+ module Agents
7
+ # DSL for declaring Rails models as LLM tenants
8
+ #
9
+ # Provides automatic budget management and usage tracking when included
10
+ # in ActiveRecord models. Models using this concern can be passed as
11
+ # the `tenant:` parameter to agents.
12
+ #
13
+ # @example Basic usage
14
+ # class Organization < ApplicationRecord
15
+ # llm_tenant
16
+ # end
17
+ #
18
+ # @example With custom ID method
19
+ # class Organization < ApplicationRecord
20
+ # llm_tenant id: :slug
21
+ # end
22
+ #
23
+ # @example With auto-created budget
24
+ # class Organization < ApplicationRecord
25
+ # llm_tenant id: :slug, budget: true
26
+ # end
27
+ #
28
+ # @example With limits (auto-creates budget)
29
+ # class Organization < ApplicationRecord
30
+ # llm_tenant(
31
+ # id: :slug,
32
+ # name: :company_name,
33
+ # limits: {
34
+ # daily_cost: 100,
35
+ # monthly_cost: 1000,
36
+ # daily_executions: 500
37
+ # },
38
+ # enforcement: :hard
39
+ # )
40
+ # end
41
+ #
42
+ # @example With API keys from model columns/methods
43
+ # class Organization < ApplicationRecord
44
+ # encrypts :openai_api_key, :anthropic_api_key # Rails 7+ encryption
45
+ #
46
+ # llm_tenant(
47
+ # id: :slug,
48
+ # api_keys: {
49
+ # openai: :openai_api_key, # column name
50
+ # anthropic: :anthropic_api_key, # column name
51
+ # gemini: :fetch_gemini_key # custom method
52
+ # }
53
+ # )
54
+ #
55
+ # def fetch_gemini_key
56
+ # Vault.read("secret/#{slug}/gemini")
57
+ # end
58
+ # end
59
+ #
60
+ # @see RubyLLM::Agents::TenantBudget
61
+ # @api public
62
+ module LLMTenant
63
+ extend ActiveSupport::Concern
64
+
65
+ included do
66
+ # Executions tracked for this tenant
67
+ has_many :llm_executions,
68
+ class_name: "RubyLLM::Agents::Execution",
69
+ as: :tenant_record,
70
+ dependent: :nullify
71
+
72
+ # Budget association (optional)
73
+ has_one :llm_budget,
74
+ class_name: "RubyLLM::Agents::TenantBudget",
75
+ as: :tenant_record,
76
+ dependent: :destroy
77
+
78
+ # Store options at class level
79
+ class_attribute :llm_tenant_options, default: {}
80
+ end
81
+
82
+ class_methods do
83
+ # Declares this model as an LLM tenant
84
+ #
85
+ # @param id [Symbol] Method to call for tenant_id string (default: :id)
86
+ # @param name [Symbol] Method for budget display name (default: :to_s)
87
+ # @param budget [Boolean] Auto-create TenantBudget on model creation (default: false)
88
+ # @param limits [Hash] Default budget limits (implies budget: true)
89
+ # @param enforcement [Symbol] Budget enforcement mode (:none, :soft, :hard)
90
+ # @param inherit_global [Boolean] Inherit from global config (default: true)
91
+ # @param api_keys [Hash] Provider API keys mapping (e.g., { openai: :openai_api_key })
92
+ # @return [void]
93
+ def llm_tenant(id: :id, name: :to_s, budget: false, limits: nil, enforcement: nil, inherit_global: true, api_keys: nil)
94
+ self.llm_tenant_options = {
95
+ id: id,
96
+ name: name,
97
+ budget: budget || limits.present?,
98
+ limits: normalize_limits(limits),
99
+ enforcement: enforcement,
100
+ inherit_global: inherit_global,
101
+ api_keys: api_keys
102
+ }
103
+
104
+ # Auto-create budget callback
105
+ after_create :create_default_llm_budget if llm_tenant_options[:budget]
106
+ end
107
+
108
+ private
109
+
110
+ # Normalizes the limits hash to internal column names
111
+ #
112
+ # @param limits [Hash, nil] User-provided limits
113
+ # @return [Hash] Normalized limits
114
+ def normalize_limits(limits)
115
+ return {} if limits.blank?
116
+
117
+ {
118
+ daily_cost: limits[:daily_cost],
119
+ monthly_cost: limits[:monthly_cost],
120
+ daily_tokens: limits[:daily_tokens],
121
+ monthly_tokens: limits[:monthly_tokens],
122
+ daily_executions: limits[:daily_executions],
123
+ monthly_executions: limits[:monthly_executions]
124
+ }.compact
125
+ end
126
+ end
127
+
128
+ # Returns the tenant_id string for this model
129
+ #
130
+ # @return [String] The tenant identifier
131
+ def llm_tenant_id
132
+ id_method = self.class.llm_tenant_options[:id] || :id
133
+ send(id_method).to_s
134
+ end
135
+
136
+ # Returns API keys resolved from the DSL configuration
137
+ #
138
+ # Maps provider names (e.g., :openai, :anthropic) to their resolved values
139
+ # by calling the configured method/column on this model instance.
140
+ #
141
+ # @return [Hash] Provider to API key mapping (e.g., { openai: "sk-..." })
142
+ # @example
143
+ # org.llm_api_keys
144
+ # # => { openai: "sk-abc123", anthropic: "sk-ant-xyz789" }
145
+ def llm_api_keys
146
+ api_keys_config = self.class.llm_tenant_options[:api_keys]
147
+ return {} if api_keys_config.blank?
148
+
149
+ api_keys_config.transform_values do |method_name|
150
+ value = send(method_name)
151
+ value.presence
152
+ end.compact
153
+ end
154
+
155
+ # Returns cost for a given period
156
+ #
157
+ # @param period [Symbol, Range, nil] Time period (:today, :this_month, etc.)
158
+ # @return [BigDecimal] Total cost
159
+ def llm_cost(period: nil)
160
+ scope = llm_executions
161
+ scope = apply_period_scope(scope, period) if period
162
+ scope.sum(:total_cost) || 0
163
+ end
164
+
165
+ # Returns cost for today
166
+ #
167
+ # @return [BigDecimal] Today's cost
168
+ def llm_cost_today
169
+ llm_cost(period: :today)
170
+ end
171
+
172
+ # Returns cost for this month
173
+ #
174
+ # @return [BigDecimal] This month's cost
175
+ def llm_cost_this_month
176
+ llm_cost(period: :this_month)
177
+ end
178
+
179
+ # Returns token count for a given period
180
+ #
181
+ # @param period [Symbol, Range, nil] Time period
182
+ # @return [Integer] Total tokens
183
+ def llm_tokens(period: nil)
184
+ scope = llm_executions
185
+ scope = apply_period_scope(scope, period) if period
186
+ scope.sum(:total_tokens) || 0
187
+ end
188
+
189
+ # Returns tokens for today
190
+ #
191
+ # @return [Integer] Today's tokens
192
+ def llm_tokens_today
193
+ llm_tokens(period: :today)
194
+ end
195
+
196
+ # Returns tokens for this month
197
+ #
198
+ # @return [Integer] This month's tokens
199
+ def llm_tokens_this_month
200
+ llm_tokens(period: :this_month)
201
+ end
202
+
203
+ # Returns execution count for a given period
204
+ #
205
+ # @param period [Symbol, Range, nil] Time period
206
+ # @return [Integer] Execution count
207
+ def llm_execution_count(period: nil)
208
+ scope = llm_executions
209
+ scope = apply_period_scope(scope, period) if period
210
+ scope.count
211
+ end
212
+
213
+ # Returns executions for today
214
+ #
215
+ # @return [Integer] Today's execution count
216
+ def llm_executions_today
217
+ llm_execution_count(period: :today)
218
+ end
219
+
220
+ # Returns executions for this month
221
+ #
222
+ # @return [Integer] This month's execution count
223
+ def llm_executions_this_month
224
+ llm_execution_count(period: :this_month)
225
+ end
226
+
227
+ # Returns a usage summary for a given period
228
+ #
229
+ # @param period [Symbol] Time period (default: :this_month)
230
+ # @return [Hash] Usage summary with cost, tokens, and executions
231
+ def llm_usage_summary(period: :this_month)
232
+ {
233
+ cost: llm_cost(period: period),
234
+ tokens: llm_tokens(period: period),
235
+ executions: llm_execution_count(period: period),
236
+ period: period
237
+ }
238
+ end
239
+
240
+ # Returns or builds the associated TenantBudget
241
+ #
242
+ # @return [TenantBudget] The budget record
243
+ def llm_budget
244
+ super || build_llm_budget(tenant_id: llm_tenant_id)
245
+ end
246
+
247
+ # Configure budget with a block
248
+ #
249
+ # @yield [budget] The budget to configure
250
+ # @return [TenantBudget] The saved budget
251
+ def llm_configure_budget
252
+ budget = llm_budget
253
+ yield(budget) if block_given?
254
+ budget.save!
255
+ budget
256
+ end
257
+
258
+ # Returns the budget status from BudgetTracker
259
+ #
260
+ # @return [Hash] Budget status
261
+ def llm_budget_status
262
+ BudgetTracker.status(tenant_id: llm_tenant_id)
263
+ end
264
+
265
+ # Checks if within budget for a given limit type
266
+ #
267
+ # @param type [Symbol] Limit type (:daily_cost, :monthly_cost, :daily_tokens, etc.)
268
+ # @return [Boolean] true if within budget
269
+ def llm_within_budget?(type: :daily_cost)
270
+ status = llm_budget_status
271
+ return true unless status[:enabled]
272
+
273
+ key = budget_status_key(type)
274
+ status.dig(key, :percentage_used).to_f < 100
275
+ end
276
+
277
+ # Returns remaining budget for a given limit type
278
+ #
279
+ # @param type [Symbol] Limit type
280
+ # @return [Numeric, nil] Remaining amount
281
+ def llm_remaining_budget(type: :daily_cost)
282
+ status = llm_budget_status
283
+ key = budget_status_key(type)
284
+ status.dig(key, :remaining)
285
+ end
286
+
287
+ # Raises an error if over budget
288
+ #
289
+ # @raise [BudgetExceededError] if budget is exceeded
290
+ # @return [void]
291
+ def llm_check_budget!
292
+ BudgetTracker.check_budget!(self.class.name, tenant_id: llm_tenant_id)
293
+ end
294
+
295
+ private
296
+
297
+ # Applies a period scope to an execution query
298
+ #
299
+ # @param scope [ActiveRecord::Relation] The query scope
300
+ # @param period [Symbol, Range] The period to filter by
301
+ # @return [ActiveRecord::Relation] Filtered scope
302
+ def apply_period_scope(scope, period)
303
+ case period
304
+ when :today then scope.where(created_at: Time.current.all_day)
305
+ when :yesterday then scope.where(created_at: 1.day.ago.all_day)
306
+ when :this_week then scope.where(created_at: Time.current.all_week)
307
+ when :this_month then scope.where(created_at: Time.current.all_month)
308
+ when Range then scope.where(created_at: period)
309
+ else scope
310
+ end
311
+ end
312
+
313
+ # Maps user-friendly type to budget status key
314
+ #
315
+ # @param type [Symbol] User-friendly type
316
+ # @return [Symbol] Status key
317
+ def budget_status_key(type)
318
+ case type
319
+ when :daily_cost then :global_daily
320
+ when :monthly_cost then :global_monthly
321
+ when :daily_tokens then :global_daily_tokens
322
+ when :monthly_tokens then :global_monthly_tokens
323
+ when :daily_executions then :global_daily_executions
324
+ when :monthly_executions then :global_monthly_executions
325
+ else :global_daily
326
+ end
327
+ end
328
+
329
+ # Creates the default budget on model creation
330
+ #
331
+ # @return [void]
332
+ def create_default_llm_budget
333
+ return if self.class.llm_tenant_options.blank?
334
+ return if llm_budget&.persisted?
335
+
336
+ options = self.class.llm_tenant_options
337
+ limits = options[:limits] || {}
338
+ name_method = options[:name] || :to_s
339
+
340
+ budget = build_llm_budget(
341
+ tenant_id: llm_tenant_id,
342
+ name: send(name_method).to_s,
343
+ daily_limit: limits[:daily_cost],
344
+ monthly_limit: limits[:monthly_cost],
345
+ daily_token_limit: limits[:daily_tokens],
346
+ monthly_token_limit: limits[:monthly_tokens],
347
+ daily_execution_limit: limits[:daily_executions],
348
+ monthly_execution_limit: limits[:monthly_executions],
349
+ enforcement: options[:enforcement]&.to_s || "soft",
350
+ inherit_global_defaults: options.fetch(:inherit_global, true)
351
+ )
352
+
353
+ budget.tenant_record = self
354
+ budget.save!
355
+ end
356
+ end
357
+ end
358
+ end