ruby_llm-agents 0.3.4 → 0.3.5
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.
- checksums.yaml +4 -4
- data/README.md +132 -1263
- data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
- data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +80 -16
- data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
- data/app/models/ruby_llm/agents/execution/analytics.rb +17 -27
- data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
- data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
- data/app/models/ruby_llm/agents/execution.rb +9 -1
- data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
- data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
- data/app/views/layouts/{rubyllm → ruby_llm}/agents/application.html.erb +91 -29
- data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
- data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
- data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
- data/app/views/{rubyllm → ruby_llm}/agents/agents/show.html.erb +77 -20
- data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
- data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
- data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
- data/app/views/{rubyllm → ruby_llm}/agents/dashboard/index.html.erb +9 -6
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
- data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
- data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
- data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +137 -126
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_breadcrumbs.html.erb +2 -2
- data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
- data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
- data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
- data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
- data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
- data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
- data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
- data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
- data/lib/ruby_llm/agents/alert_manager.rb +20 -16
- data/lib/ruby_llm/agents/base/caching.rb +4 -7
- data/lib/ruby_llm/agents/base/cost_calculation.rb +5 -3
- data/lib/ruby_llm/agents/base/execution.rb +61 -9
- data/lib/ruby_llm/agents/base/reliability_execution.rb +14 -9
- data/lib/ruby_llm/agents/base.rb +26 -0
- data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
- data/lib/ruby_llm/agents/cache_helper.rb +98 -0
- data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
- data/lib/ruby_llm/agents/configuration.rb +40 -1
- data/lib/ruby_llm/agents/engine.rb +65 -1
- data/lib/ruby_llm/agents/inflections.rb +14 -0
- data/lib/ruby_llm/agents/instrumentation.rb +66 -0
- data/lib/ruby_llm/agents/reliability.rb +8 -2
- data/lib/ruby_llm/agents/version.rb +1 -1
- data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
- data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
- data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
- data/lib/ruby_llm/agents/workflow/result.rb +390 -0
- data/lib/ruby_llm/agents/workflow/router.rb +429 -0
- data/lib/ruby_llm/agents/workflow.rb +232 -0
- data/lib/ruby_llm/agents.rb +1 -0
- metadata +50 -60
- data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
- data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +0 -46
- data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
- data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
- data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
- /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_budgets_bar.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_now_strip.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_top_errors.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/shared/_nav_link.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/shared/_stat_card.html.erb +0 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Execution
|
|
6
|
+
# Workflow concern for workflow-related methods and aggregate calculations
|
|
7
|
+
#
|
|
8
|
+
# Provides instance methods for determining workflow type, calculating
|
|
9
|
+
# aggregate statistics across child executions, and retrieving workflow
|
|
10
|
+
# step/branch information.
|
|
11
|
+
#
|
|
12
|
+
# @see RubyLLM::Agents::Execution
|
|
13
|
+
# @api public
|
|
14
|
+
module Workflow
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
# Returns whether this is a workflow execution (has workflow_type)
|
|
18
|
+
#
|
|
19
|
+
# @return [Boolean] true if this is a workflow execution
|
|
20
|
+
def workflow?
|
|
21
|
+
workflow_type.present?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns whether this is a pipeline workflow
|
|
25
|
+
#
|
|
26
|
+
# @return [Boolean] true if workflow_type is "pipeline"
|
|
27
|
+
def pipeline_workflow?
|
|
28
|
+
workflow_type == "pipeline"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns whether this is a parallel workflow
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean] true if workflow_type is "parallel"
|
|
34
|
+
def parallel_workflow?
|
|
35
|
+
workflow_type == "parallel"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns whether this is a router workflow
|
|
39
|
+
#
|
|
40
|
+
# @return [Boolean] true if workflow_type is "router"
|
|
41
|
+
def router_workflow?
|
|
42
|
+
workflow_type == "router"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns whether this is a root workflow execution (top-level)
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean] true if this is a workflow with no parent
|
|
48
|
+
def root_workflow?
|
|
49
|
+
workflow? && root?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns all workflow steps/branches ordered by creation time
|
|
53
|
+
#
|
|
54
|
+
# @return [ActiveRecord::Relation] Child executions for this workflow
|
|
55
|
+
def workflow_steps
|
|
56
|
+
child_executions.order(:created_at)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the count of child workflow steps
|
|
60
|
+
#
|
|
61
|
+
# @return [Integer] Number of child executions
|
|
62
|
+
def workflow_steps_count
|
|
63
|
+
child_executions.count
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @!group Aggregate Statistics
|
|
67
|
+
|
|
68
|
+
# Returns aggregate stats for all child executions
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash] Aggregated metrics including cost, tokens, duration
|
|
71
|
+
def workflow_aggregate_stats
|
|
72
|
+
return @workflow_aggregate_stats if defined?(@workflow_aggregate_stats)
|
|
73
|
+
|
|
74
|
+
children = child_executions.to_a
|
|
75
|
+
return empty_aggregate_stats if children.empty?
|
|
76
|
+
|
|
77
|
+
@workflow_aggregate_stats = {
|
|
78
|
+
total_cost: children.sum { |c| c.total_cost || 0 },
|
|
79
|
+
total_tokens: children.sum { |c| c.total_tokens || 0 },
|
|
80
|
+
input_tokens: children.sum { |c| c.input_tokens || 0 },
|
|
81
|
+
output_tokens: children.sum { |c| c.output_tokens || 0 },
|
|
82
|
+
total_duration_ms: children.sum { |c| c.duration_ms || 0 },
|
|
83
|
+
wall_clock_ms: calculate_wall_clock_duration(children),
|
|
84
|
+
steps_count: children.size,
|
|
85
|
+
successful_count: children.count(&:status_success?),
|
|
86
|
+
failed_count: children.count(&:status_error?),
|
|
87
|
+
timeout_count: children.count(&:status_timeout?),
|
|
88
|
+
running_count: children.count(&:status_running?),
|
|
89
|
+
success_rate: calculate_success_rate(children),
|
|
90
|
+
models_used: children.map(&:model_id).uniq.compact
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns aggregate total cost across all child executions
|
|
95
|
+
#
|
|
96
|
+
# @return [Float] Total cost in USD
|
|
97
|
+
def workflow_total_cost
|
|
98
|
+
workflow_aggregate_stats[:total_cost]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns aggregate total tokens across all child executions
|
|
102
|
+
#
|
|
103
|
+
# @return [Integer] Total tokens used
|
|
104
|
+
def workflow_total_tokens
|
|
105
|
+
workflow_aggregate_stats[:total_tokens]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Returns the wall-clock duration (from first start to last completion)
|
|
109
|
+
#
|
|
110
|
+
# @return [Integer, nil] Duration in milliseconds
|
|
111
|
+
def workflow_wall_clock_ms
|
|
112
|
+
workflow_aggregate_stats[:wall_clock_ms]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns the sum of all step durations (may exceed wall-clock for parallel)
|
|
116
|
+
#
|
|
117
|
+
# @return [Integer] Sum of all durations in milliseconds
|
|
118
|
+
def workflow_sum_duration_ms
|
|
119
|
+
workflow_aggregate_stats[:total_duration_ms]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Returns the overall workflow status based on child executions
|
|
123
|
+
#
|
|
124
|
+
# @return [Symbol] :success, :error, :timeout, :running, or :pending
|
|
125
|
+
def workflow_overall_status
|
|
126
|
+
stats = workflow_aggregate_stats
|
|
127
|
+
return :pending if stats[:steps_count].zero?
|
|
128
|
+
return :running if stats[:running_count] > 0
|
|
129
|
+
return :error if stats[:failed_count] > 0
|
|
130
|
+
return :timeout if stats[:timeout_count] > 0
|
|
131
|
+
|
|
132
|
+
:success
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @!endgroup
|
|
136
|
+
|
|
137
|
+
# @!group Pipeline-specific Methods
|
|
138
|
+
|
|
139
|
+
# Returns pipeline steps in order with their status
|
|
140
|
+
#
|
|
141
|
+
# @return [Array<Hash>] Array of step hashes with name, status, duration, cost
|
|
142
|
+
def pipeline_steps_detail
|
|
143
|
+
return [] unless pipeline_workflow?
|
|
144
|
+
|
|
145
|
+
workflow_steps.map do |step|
|
|
146
|
+
{
|
|
147
|
+
id: step.id,
|
|
148
|
+
name: step.workflow_step || step.agent_type.gsub(/Agent$/, ""),
|
|
149
|
+
agent_type: step.agent_type,
|
|
150
|
+
status: step.status,
|
|
151
|
+
duration_ms: step.duration_ms,
|
|
152
|
+
total_cost: step.total_cost,
|
|
153
|
+
total_tokens: step.total_tokens,
|
|
154
|
+
model_id: step.model_id
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @!endgroup
|
|
160
|
+
|
|
161
|
+
# @!group Parallel-specific Methods
|
|
162
|
+
|
|
163
|
+
# Returns parallel branches with their status and timing
|
|
164
|
+
#
|
|
165
|
+
# @return [Array<Hash>] Array of branch hashes
|
|
166
|
+
def parallel_branches_detail
|
|
167
|
+
return [] unless parallel_workflow?
|
|
168
|
+
|
|
169
|
+
branches = workflow_steps.to_a
|
|
170
|
+
return [] if branches.empty?
|
|
171
|
+
|
|
172
|
+
# Find min/max for timing comparison
|
|
173
|
+
min_duration = branches.map { |b| b.duration_ms || 0 }.min
|
|
174
|
+
max_duration = branches.map { |b| b.duration_ms || 0 }.max
|
|
175
|
+
|
|
176
|
+
branches.map do |branch|
|
|
177
|
+
duration = branch.duration_ms || 0
|
|
178
|
+
{
|
|
179
|
+
id: branch.id,
|
|
180
|
+
name: branch.workflow_step || branch.agent_type.gsub(/Agent$/, ""),
|
|
181
|
+
agent_type: branch.agent_type,
|
|
182
|
+
status: branch.status,
|
|
183
|
+
duration_ms: duration,
|
|
184
|
+
total_cost: branch.total_cost,
|
|
185
|
+
total_tokens: branch.total_tokens,
|
|
186
|
+
model_id: branch.model_id,
|
|
187
|
+
is_fastest: duration == min_duration && branches.size > 1,
|
|
188
|
+
is_slowest: duration == max_duration && branches.size > 1 && min_duration != max_duration
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# @!endgroup
|
|
194
|
+
|
|
195
|
+
# @!group Router-specific Methods
|
|
196
|
+
|
|
197
|
+
# Returns router classification details
|
|
198
|
+
#
|
|
199
|
+
# @return [Hash] Classification info including method, model, timing
|
|
200
|
+
def router_classification_detail
|
|
201
|
+
return {} unless router_workflow?
|
|
202
|
+
|
|
203
|
+
result = if classification_result.is_a?(String)
|
|
204
|
+
begin
|
|
205
|
+
JSON.parse(classification_result)
|
|
206
|
+
rescue JSON::ParserError
|
|
207
|
+
{}
|
|
208
|
+
end
|
|
209
|
+
else
|
|
210
|
+
classification_result || {}
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
{
|
|
214
|
+
method: result["method"],
|
|
215
|
+
classifier_model: result["classifier_model"],
|
|
216
|
+
classification_time_ms: result["classification_time_ms"],
|
|
217
|
+
routed_to: routed_to,
|
|
218
|
+
confidence: result["confidence"]
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Returns available routes and which one was chosen
|
|
223
|
+
#
|
|
224
|
+
# @return [Hash] Routes info with chosen route highlighted
|
|
225
|
+
def router_routes_detail
|
|
226
|
+
return {} unless router_workflow?
|
|
227
|
+
|
|
228
|
+
# Get the routed execution (child)
|
|
229
|
+
routed_child = child_executions.first
|
|
230
|
+
|
|
231
|
+
{
|
|
232
|
+
chosen_route: routed_to,
|
|
233
|
+
routed_execution: routed_child ? {
|
|
234
|
+
id: routed_child.id,
|
|
235
|
+
agent_type: routed_child.agent_type,
|
|
236
|
+
status: routed_child.status,
|
|
237
|
+
duration_ms: routed_child.duration_ms,
|
|
238
|
+
total_cost: routed_child.total_cost
|
|
239
|
+
} : nil
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# @!endgroup
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
# Returns empty aggregate stats hash
|
|
248
|
+
#
|
|
249
|
+
# @return [Hash] Empty stats with zero values
|
|
250
|
+
def empty_aggregate_stats
|
|
251
|
+
{
|
|
252
|
+
total_cost: 0,
|
|
253
|
+
total_tokens: 0,
|
|
254
|
+
input_tokens: 0,
|
|
255
|
+
output_tokens: 0,
|
|
256
|
+
total_duration_ms: 0,
|
|
257
|
+
wall_clock_ms: nil,
|
|
258
|
+
steps_count: 0,
|
|
259
|
+
successful_count: 0,
|
|
260
|
+
failed_count: 0,
|
|
261
|
+
timeout_count: 0,
|
|
262
|
+
running_count: 0,
|
|
263
|
+
success_rate: 0.0,
|
|
264
|
+
models_used: []
|
|
265
|
+
}
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Calculates wall-clock duration from child executions
|
|
269
|
+
#
|
|
270
|
+
# @param children [Array<Execution>] Child executions
|
|
271
|
+
# @return [Integer, nil] Duration in milliseconds
|
|
272
|
+
def calculate_wall_clock_duration(children)
|
|
273
|
+
started_times = children.map(&:started_at).compact
|
|
274
|
+
completed_times = children.map(&:completed_at).compact
|
|
275
|
+
|
|
276
|
+
return nil if started_times.empty? || completed_times.empty?
|
|
277
|
+
|
|
278
|
+
first_start = started_times.min
|
|
279
|
+
last_complete = completed_times.max
|
|
280
|
+
|
|
281
|
+
((last_complete - first_start) * 1000).round
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Calculates success rate from children
|
|
285
|
+
#
|
|
286
|
+
# @param children [Array<Execution>] Child executions
|
|
287
|
+
# @return [Float] Success rate as percentage
|
|
288
|
+
def calculate_success_rate(children)
|
|
289
|
+
return 0.0 if children.empty?
|
|
290
|
+
|
|
291
|
+
completed = children.reject(&:status_running?)
|
|
292
|
+
return 0.0 if completed.empty?
|
|
293
|
+
|
|
294
|
+
(completed.count(&:status_success?).to_f / completed.size * 100).round(1)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -51,6 +51,7 @@ module RubyLLM
|
|
|
51
51
|
include Execution::Metrics
|
|
52
52
|
include Execution::Scopes
|
|
53
53
|
include Execution::Analytics
|
|
54
|
+
include Execution::Workflow
|
|
54
55
|
|
|
55
56
|
# Status enum
|
|
56
57
|
# - running: execution in progress
|
|
@@ -123,13 +124,20 @@ module RubyLLM
|
|
|
123
124
|
#
|
|
124
125
|
# @return [Boolean] true if more than one attempt was made
|
|
125
126
|
def has_retries?
|
|
126
|
-
(attempts_count
|
|
127
|
+
count = if self.class.column_names.include?("attempts_count")
|
|
128
|
+
attempts_count
|
|
129
|
+
elsif self.class.column_names.include?("attempts")
|
|
130
|
+
attempts&.size
|
|
131
|
+
end
|
|
132
|
+
(count || 0) > 1
|
|
127
133
|
end
|
|
128
134
|
|
|
129
135
|
# Returns whether this execution used fallback models
|
|
130
136
|
#
|
|
131
137
|
# @return [Boolean] true if a different model than requested succeeded
|
|
132
138
|
def used_fallback?
|
|
139
|
+
return false unless self.class.column_names.include?("chosen_model_id")
|
|
140
|
+
|
|
133
141
|
chosen_model_id.present? && chosen_model_id != model_id
|
|
134
142
|
end
|
|
135
143
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
# Database-backed budget configuration for multi-tenant environments
|
|
6
|
+
#
|
|
7
|
+
# Stores per-tenant budget limits that override the global configuration.
|
|
8
|
+
# Supports runtime updates without application restarts.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [rw] tenant_id
|
|
11
|
+
# @return [String] Unique identifier for the tenant
|
|
12
|
+
# @!attribute [rw] daily_limit
|
|
13
|
+
# @return [BigDecimal, nil] Daily budget limit in USD
|
|
14
|
+
# @!attribute [rw] monthly_limit
|
|
15
|
+
# @return [BigDecimal, nil] Monthly budget limit in USD
|
|
16
|
+
# @!attribute [rw] per_agent_daily
|
|
17
|
+
# @return [Hash] Per-agent daily limits: { "AgentName" => limit }
|
|
18
|
+
# @!attribute [rw] per_agent_monthly
|
|
19
|
+
# @return [Hash] Per-agent monthly limits: { "AgentName" => limit }
|
|
20
|
+
# @!attribute [rw] enforcement
|
|
21
|
+
# @return [String] Enforcement mode: "none", "soft", or "hard"
|
|
22
|
+
# @!attribute [rw] inherit_global_defaults
|
|
23
|
+
# @return [Boolean] Whether to fall back to global config for unset limits
|
|
24
|
+
#
|
|
25
|
+
# @example Creating a tenant budget
|
|
26
|
+
# TenantBudget.create!(
|
|
27
|
+
# tenant_id: "acme_corp",
|
|
28
|
+
# daily_limit: 50.0,
|
|
29
|
+
# monthly_limit: 500.0,
|
|
30
|
+
# per_agent_daily: { "ContentAgent" => 10.0 },
|
|
31
|
+
# enforcement: "hard"
|
|
32
|
+
# )
|
|
33
|
+
#
|
|
34
|
+
# @example Fetching budget for a tenant
|
|
35
|
+
# budget = TenantBudget.for_tenant("acme_corp")
|
|
36
|
+
# budget.effective_daily_limit # => 50.0
|
|
37
|
+
#
|
|
38
|
+
# @see RubyLLM::Agents::BudgetTracker
|
|
39
|
+
# @api public
|
|
40
|
+
class TenantBudget < ::ActiveRecord::Base
|
|
41
|
+
self.table_name = "ruby_llm_agents_tenant_budgets"
|
|
42
|
+
|
|
43
|
+
# Valid enforcement modes
|
|
44
|
+
ENFORCEMENT_MODES = %w[none soft hard].freeze
|
|
45
|
+
|
|
46
|
+
# Validations
|
|
47
|
+
validates :tenant_id, presence: true, uniqueness: true
|
|
48
|
+
validates :enforcement, inclusion: { in: ENFORCEMENT_MODES }, allow_nil: true
|
|
49
|
+
validates :daily_limit, :monthly_limit,
|
|
50
|
+
numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
|
51
|
+
|
|
52
|
+
# Finds a budget for the given tenant
|
|
53
|
+
#
|
|
54
|
+
# @param tenant_id [String] The tenant identifier
|
|
55
|
+
# @return [TenantBudget, nil] The budget record or nil if not found
|
|
56
|
+
def self.for_tenant(tenant_id)
|
|
57
|
+
return nil if tenant_id.blank?
|
|
58
|
+
|
|
59
|
+
find_by(tenant_id: tenant_id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the effective daily limit, considering inheritance
|
|
63
|
+
#
|
|
64
|
+
# @return [Float, nil] The daily limit or nil if not set
|
|
65
|
+
def effective_daily_limit
|
|
66
|
+
return daily_limit if daily_limit.present?
|
|
67
|
+
return nil unless inherit_global_defaults
|
|
68
|
+
|
|
69
|
+
global_config&.dig(:global_daily)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the effective monthly limit, considering inheritance
|
|
73
|
+
#
|
|
74
|
+
# @return [Float, nil] The monthly limit or nil if not set
|
|
75
|
+
def effective_monthly_limit
|
|
76
|
+
return monthly_limit if monthly_limit.present?
|
|
77
|
+
return nil unless inherit_global_defaults
|
|
78
|
+
|
|
79
|
+
global_config&.dig(:global_monthly)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns the effective per-agent daily limit
|
|
83
|
+
#
|
|
84
|
+
# @param agent_type [String] The agent class name
|
|
85
|
+
# @return [Float, nil] The limit or nil if not set
|
|
86
|
+
def effective_per_agent_daily(agent_type)
|
|
87
|
+
limit = per_agent_daily&.dig(agent_type)
|
|
88
|
+
return limit if limit.present?
|
|
89
|
+
return nil unless inherit_global_defaults
|
|
90
|
+
|
|
91
|
+
global_config&.dig(:per_agent_daily, agent_type)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns the effective per-agent monthly limit
|
|
95
|
+
#
|
|
96
|
+
# @param agent_type [String] The agent class name
|
|
97
|
+
# @return [Float, nil] The limit or nil if not set
|
|
98
|
+
def effective_per_agent_monthly(agent_type)
|
|
99
|
+
limit = per_agent_monthly&.dig(agent_type)
|
|
100
|
+
return limit if limit.present?
|
|
101
|
+
return nil unless inherit_global_defaults
|
|
102
|
+
|
|
103
|
+
global_config&.dig(:per_agent_monthly, agent_type)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns the effective enforcement mode
|
|
107
|
+
#
|
|
108
|
+
# @return [Symbol] :none, :soft, or :hard
|
|
109
|
+
def effective_enforcement
|
|
110
|
+
return enforcement.to_sym if enforcement.present?
|
|
111
|
+
return :soft unless inherit_global_defaults
|
|
112
|
+
|
|
113
|
+
RubyLLM::Agents.configuration.budget_enforcement
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Checks if budget enforcement is enabled for this tenant
|
|
117
|
+
#
|
|
118
|
+
# @return [Boolean] true if enforcement is :soft or :hard
|
|
119
|
+
def budgets_enabled?
|
|
120
|
+
effective_enforcement != :none
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns a hash suitable for BudgetTracker
|
|
124
|
+
#
|
|
125
|
+
# @return [Hash] Budget configuration hash
|
|
126
|
+
def to_budget_config
|
|
127
|
+
{
|
|
128
|
+
enabled: budgets_enabled?,
|
|
129
|
+
enforcement: effective_enforcement,
|
|
130
|
+
global_daily: effective_daily_limit,
|
|
131
|
+
global_monthly: effective_monthly_limit,
|
|
132
|
+
per_agent_daily: merged_per_agent_daily,
|
|
133
|
+
per_agent_monthly: merged_per_agent_monthly
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Returns the global budgets configuration
|
|
140
|
+
#
|
|
141
|
+
# @return [Hash, nil] Global budget config
|
|
142
|
+
def global_config
|
|
143
|
+
RubyLLM::Agents.configuration.budgets
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Merges per-agent daily limits with global defaults
|
|
147
|
+
#
|
|
148
|
+
# @return [Hash] Merged per-agent daily limits
|
|
149
|
+
def merged_per_agent_daily
|
|
150
|
+
return per_agent_daily || {} unless inherit_global_defaults
|
|
151
|
+
|
|
152
|
+
(global_config&.dig(:per_agent_daily) || {}).merge(per_agent_daily || {})
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Merges per-agent monthly limits with global defaults
|
|
156
|
+
#
|
|
157
|
+
# @return [Hash] Merged per-agent monthly limits
|
|
158
|
+
def merged_per_agent_monthly
|
|
159
|
+
return per_agent_monthly || {} unless inherit_global_defaults
|
|
160
|
+
|
|
161
|
+
(global_config&.dig(:per_agent_monthly) || {}).merge(per_agent_monthly || {})
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -95,6 +95,28 @@ module RubyLLM
|
|
|
95
95
|
end
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
# Returns only regular agents (non-workflows)
|
|
99
|
+
#
|
|
100
|
+
# @return [Array<Hash>] Agent info hashes for non-workflow agents
|
|
101
|
+
def agents_only
|
|
102
|
+
all_with_details.reject { |a| a[:is_workflow] }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns only workflows
|
|
106
|
+
#
|
|
107
|
+
# @return [Array<Hash>] Agent info hashes for workflows only
|
|
108
|
+
def workflows_only
|
|
109
|
+
all_with_details.select { |a| a[:is_workflow] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns workflows filtered by type
|
|
113
|
+
#
|
|
114
|
+
# @param type [String, Symbol] The workflow type (pipeline, parallel, router)
|
|
115
|
+
# @return [Array<Hash>] Filtered workflow info hashes
|
|
116
|
+
def workflows_by_type(type)
|
|
117
|
+
workflows_only.select { |w| w[:workflow_type] == type.to_s }
|
|
118
|
+
end
|
|
119
|
+
|
|
98
120
|
# Builds detailed info hash for an agent
|
|
99
121
|
#
|
|
100
122
|
# @param agent_type [String] The agent class name
|
|
@@ -103,17 +125,27 @@ module RubyLLM
|
|
|
103
125
|
agent_class = find(agent_type)
|
|
104
126
|
stats = fetch_stats(agent_type)
|
|
105
127
|
|
|
128
|
+
# Check if this is a workflow class vs a regular agent
|
|
129
|
+
is_workflow = agent_class&.ancestors&.any? { |a| a.name&.include?("Workflow") }
|
|
130
|
+
|
|
131
|
+
# Determine specific workflow type and children
|
|
132
|
+
workflow_type = is_workflow ? detect_workflow_type(agent_class) : nil
|
|
133
|
+
workflow_children = is_workflow ? extract_workflow_children(agent_class) : []
|
|
134
|
+
|
|
106
135
|
{
|
|
107
136
|
name: agent_type,
|
|
108
137
|
class: agent_class,
|
|
109
138
|
active: agent_class.present?,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
is_workflow: is_workflow,
|
|
140
|
+
workflow_type: workflow_type,
|
|
141
|
+
workflow_children: workflow_children,
|
|
142
|
+
version: safe_call(agent_class, :version) || "N/A",
|
|
143
|
+
model: safe_call(agent_class, :model) || (is_workflow ? "workflow" : "N/A"),
|
|
144
|
+
temperature: safe_call(agent_class, :temperature),
|
|
145
|
+
timeout: safe_call(agent_class, :timeout),
|
|
146
|
+
cache_enabled: safe_call(agent_class, :cache_enabled?) || false,
|
|
147
|
+
cache_ttl: safe_call(agent_class, :cache_ttl),
|
|
148
|
+
params: safe_call(agent_class, :params) || {},
|
|
117
149
|
execution_count: stats[:count],
|
|
118
150
|
total_cost: stats[:total_cost],
|
|
119
151
|
total_tokens: stats[:total_tokens],
|
|
@@ -124,6 +156,20 @@ module RubyLLM
|
|
|
124
156
|
}
|
|
125
157
|
end
|
|
126
158
|
|
|
159
|
+
# Safely calls a method on a class, returning nil if method doesn't exist
|
|
160
|
+
#
|
|
161
|
+
# @param klass [Class, nil] The class to call the method on
|
|
162
|
+
# @param method_name [Symbol] The method to call
|
|
163
|
+
# @return [Object, nil] The result or nil
|
|
164
|
+
def safe_call(klass, method_name)
|
|
165
|
+
return nil unless klass
|
|
166
|
+
return nil unless klass.respond_to?(method_name)
|
|
167
|
+
|
|
168
|
+
klass.public_send(method_name)
|
|
169
|
+
rescue StandardError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
127
173
|
# Fetches statistics for an agent
|
|
128
174
|
#
|
|
129
175
|
# @param agent_type [String] The agent class name
|
|
@@ -143,6 +189,71 @@ module RubyLLM
|
|
|
143
189
|
rescue StandardError
|
|
144
190
|
nil
|
|
145
191
|
end
|
|
192
|
+
|
|
193
|
+
# Detects the specific workflow type from class hierarchy
|
|
194
|
+
#
|
|
195
|
+
# @param agent_class [Class, nil] The agent class
|
|
196
|
+
# @return [String, nil] "pipeline", "parallel", "router", or nil
|
|
197
|
+
def detect_workflow_type(agent_class)
|
|
198
|
+
return nil unless agent_class
|
|
199
|
+
|
|
200
|
+
ancestors = agent_class.ancestors.map { |a| a.name.to_s }
|
|
201
|
+
|
|
202
|
+
if ancestors.include?("RubyLLM::Agents::Workflow::Pipeline")
|
|
203
|
+
"pipeline"
|
|
204
|
+
elsif ancestors.include?("RubyLLM::Agents::Workflow::Parallel")
|
|
205
|
+
"parallel"
|
|
206
|
+
elsif ancestors.include?("RubyLLM::Agents::Workflow::Router")
|
|
207
|
+
"router"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Extracts child agents from workflow DSL configuration
|
|
212
|
+
#
|
|
213
|
+
# @param agent_class [Class, nil] The workflow class
|
|
214
|
+
# @return [Array<Hash>] Array of child info hashes with :name, :agent, :type, :optional keys
|
|
215
|
+
def extract_workflow_children(agent_class)
|
|
216
|
+
return [] unless agent_class
|
|
217
|
+
|
|
218
|
+
children = []
|
|
219
|
+
|
|
220
|
+
if agent_class.respond_to?(:steps) && agent_class.steps.any?
|
|
221
|
+
# Pipeline workflow - extract steps
|
|
222
|
+
agent_class.steps.each do |name, config|
|
|
223
|
+
children << {
|
|
224
|
+
name: name,
|
|
225
|
+
agent: config[:agent]&.name,
|
|
226
|
+
type: "step",
|
|
227
|
+
optional: config[:continue_on_error] || false
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
elsif agent_class.respond_to?(:branches) && agent_class.branches.any?
|
|
231
|
+
# Parallel workflow - extract branches
|
|
232
|
+
agent_class.branches.each do |name, config|
|
|
233
|
+
children << {
|
|
234
|
+
name: name,
|
|
235
|
+
agent: config[:agent]&.name,
|
|
236
|
+
type: "branch",
|
|
237
|
+
optional: config[:optional] || false
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
elsif agent_class.respond_to?(:routes) && agent_class.routes.any?
|
|
241
|
+
# Router workflow - extract routes
|
|
242
|
+
agent_class.routes.each do |name, config|
|
|
243
|
+
children << {
|
|
244
|
+
name: name,
|
|
245
|
+
agent: config[:agent]&.name,
|
|
246
|
+
type: "route",
|
|
247
|
+
description: config[:description]
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
children
|
|
253
|
+
rescue StandardError => e
|
|
254
|
+
Rails.logger.error("[RubyLLM::Agents] Error extracting workflow children: #{e.message}")
|
|
255
|
+
[]
|
|
256
|
+
end
|
|
146
257
|
end
|
|
147
258
|
end
|
|
148
259
|
end
|