ruby_llm-agents 0.3.3 → 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 +137 -9
- data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
- data/app/models/ruby_llm/agents/execution/analytics.rb +103 -12
- 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 +28 -59
- 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/ruby_llm/agents/application.html.erb +430 -0
- 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/ruby_llm/agents/agents/show.html.erb +775 -0
- data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
- data/app/views/ruby_llm/agents/dashboard/_budgets_bar.html.erb +75 -0
- data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
- data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +84 -0
- data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
- data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +49 -0
- data/app/views/ruby_llm/agents/dashboard/index.html.erb +155 -0
- 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 +260 -200
- data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_breadcrumbs.html.erb +48 -0
- 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/ruby_llm/agents/shared/_nav_link.html.erb +27 -0
- 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/config/routes.rb +2 -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 +40 -0
- data/lib/ruby_llm/agents/base/cost_calculation.rb +105 -0
- data/lib/ruby_llm/agents/base/dsl.rb +261 -0
- data/lib/ruby_llm/agents/base/execution.rb +258 -0
- data/lib/ruby_llm/agents/base/reliability_execution.rb +136 -0
- data/lib/ruby_llm/agents/base/response_building.rb +86 -0
- data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
- data/lib/ruby_llm/agents/base.rb +37 -801
- 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 +57 -75
- data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
- data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
- data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
- data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
- data/app/views/layouts/rubyllm/agents/application.html.erb +0 -626
- data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
- data/app/views/rubyllm/agents/agents/show.html.erb +0 -772
- data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -165
- data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +0 -10
- data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
- data/app/views/rubyllm/agents/dashboard/index.html.erb +0 -197
- 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/executions/dry_run.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
|
|
@@ -85,7 +86,6 @@ module RubyLLM
|
|
|
85
86
|
|
|
86
87
|
before_save :calculate_total_tokens, if: -> { input_tokens_changed? || output_tokens_changed? }
|
|
87
88
|
before_save :calculate_total_cost, if: -> { input_cost_changed? || output_cost_changed? }
|
|
88
|
-
after_commit :broadcast_turbo_streams, on: %i[create update]
|
|
89
89
|
|
|
90
90
|
# Aggregates costs from all attempts using each attempt's model pricing
|
|
91
91
|
#
|
|
@@ -124,13 +124,20 @@ module RubyLLM
|
|
|
124
124
|
#
|
|
125
125
|
# @return [Boolean] true if more than one attempt was made
|
|
126
126
|
def has_retries?
|
|
127
|
-
(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
|
|
128
133
|
end
|
|
129
134
|
|
|
130
135
|
# Returns whether this execution used fallback models
|
|
131
136
|
#
|
|
132
137
|
# @return [Boolean] true if a different model than requested succeeded
|
|
133
138
|
def used_fallback?
|
|
139
|
+
return false unless self.class.column_names.include?("chosen_model_id")
|
|
140
|
+
|
|
134
141
|
chosen_model_id.present? && chosen_model_id != model_id
|
|
135
142
|
end
|
|
136
143
|
|
|
@@ -228,77 +235,39 @@ module RubyLLM
|
|
|
228
235
|
|
|
229
236
|
# Returns real-time dashboard data for the Now Strip
|
|
230
237
|
#
|
|
238
|
+
# @param range [String] Time range: "today", "7d", or "30d"
|
|
231
239
|
# @return [Hash] Now strip metrics
|
|
232
|
-
def self.now_strip_data
|
|
233
|
-
|
|
240
|
+
def self.now_strip_data(range: "today")
|
|
241
|
+
scope = case range
|
|
242
|
+
when "7d" then last_n_days(7)
|
|
243
|
+
when "30d" then last_n_days(30)
|
|
244
|
+
else today
|
|
245
|
+
end
|
|
246
|
+
|
|
234
247
|
{
|
|
235
248
|
running: running.count,
|
|
236
|
-
success_today:
|
|
237
|
-
errors_today:
|
|
238
|
-
timeouts_today:
|
|
239
|
-
cost_today:
|
|
240
|
-
executions_today:
|
|
241
|
-
success_rate:
|
|
249
|
+
success_today: scope.status_success.count,
|
|
250
|
+
errors_today: scope.status_error.count,
|
|
251
|
+
timeouts_today: scope.status_timeout.count,
|
|
252
|
+
cost_today: scope.sum(:total_cost) || 0,
|
|
253
|
+
executions_today: scope.count,
|
|
254
|
+
success_rate: calculate_period_success_rate(scope)
|
|
242
255
|
}
|
|
243
256
|
end
|
|
244
257
|
|
|
245
|
-
# Calculates
|
|
258
|
+
# Calculates success rate for a given scope
|
|
246
259
|
#
|
|
260
|
+
# @param scope [ActiveRecord::Relation] The execution scope
|
|
247
261
|
# @return [Float] Success rate as percentage
|
|
248
|
-
def self.
|
|
249
|
-
total =
|
|
262
|
+
def self.calculate_period_success_rate(scope)
|
|
263
|
+
total = scope.count
|
|
250
264
|
return 0.0 if total.zero?
|
|
251
265
|
|
|
252
|
-
(
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Broadcasts execution changes via ActionCable for real-time dashboard updates
|
|
256
|
-
#
|
|
257
|
-
# Sends JSON with action, id, status, and rendered HTML partials.
|
|
258
|
-
# The JavaScript client handles DOM updates based on the action type.
|
|
259
|
-
#
|
|
260
|
-
# @return [void]
|
|
261
|
-
def broadcast_turbo_streams
|
|
262
|
-
ActionCable.server.broadcast(
|
|
263
|
-
"ruby_llm_agents:executions",
|
|
264
|
-
{
|
|
265
|
-
action: previously_new_record? ? "created" : "updated",
|
|
266
|
-
id: id,
|
|
267
|
-
status: status,
|
|
268
|
-
html: render_execution_html,
|
|
269
|
-
now_strip_html: render_now_strip_html
|
|
270
|
-
}
|
|
271
|
-
)
|
|
272
|
-
rescue StandardError => e
|
|
273
|
-
Rails.logger.error("[RubyLLM::Agents] Failed to broadcast execution: #{e.message}")
|
|
266
|
+
(scope.successful.count.to_f / total * 100).round(1)
|
|
274
267
|
end
|
|
275
268
|
|
|
276
269
|
private
|
|
277
270
|
|
|
278
|
-
# Renders the execution item partial for broadcast
|
|
279
|
-
#
|
|
280
|
-
# @return [String, nil] HTML string or nil if rendering fails
|
|
281
|
-
def render_execution_html
|
|
282
|
-
ApplicationController.render(
|
|
283
|
-
partial: "rubyllm/agents/dashboard/execution_item",
|
|
284
|
-
locals: { execution: self }
|
|
285
|
-
)
|
|
286
|
-
rescue StandardError
|
|
287
|
-
nil
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
# Renders the Now Strip values partial for broadcast
|
|
291
|
-
#
|
|
292
|
-
# @return [String, nil] HTML string or nil if rendering fails
|
|
293
|
-
def render_now_strip_html
|
|
294
|
-
ApplicationController.render(
|
|
295
|
-
partial: "rubyllm/agents/dashboard/now_strip_values",
|
|
296
|
-
locals: { now_strip: self.class.now_strip_data }
|
|
297
|
-
)
|
|
298
|
-
rescue StandardError
|
|
299
|
-
nil
|
|
300
|
-
end
|
|
301
|
-
|
|
302
271
|
# Calculates and sets total_tokens from input and output
|
|
303
272
|
#
|
|
304
273
|
# @return [Integer] The calculated total
|
|
@@ -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
|