ruby_llm-agents 0.2.4 → 0.3.0
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 +273 -0
- data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
- data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
- data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
- data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
- data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
- data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
- data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
- data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
- data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
- data/app/models/ruby_llm/agents/execution.rb +259 -16
- data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
- data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
- data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
- data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
- data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
- data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
- data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
- data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
- data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
- data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
- data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
- data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
- data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
- data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
- data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
- data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
- data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
- data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
- data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
- data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
- data/config/routes.rb +7 -0
- data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
- data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
- data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
- data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
- data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
- data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
- data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
- data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
- data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +139 -8
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
- data/lib/ruby_llm/agents/alert_manager.rb +207 -0
- data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
- data/lib/ruby_llm/agents/base.rb +580 -112
- data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
- data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
- data/lib/ruby_llm/agents/configuration.rb +279 -1
- data/lib/ruby_llm/agents/engine.rb +58 -6
- data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
- data/lib/ruby_llm/agents/inflections.rb +13 -2
- data/lib/ruby_llm/agents/instrumentation.rb +538 -87
- data/lib/ruby_llm/agents/redactor.rb +130 -0
- data/lib/ruby_llm/agents/reliability.rb +185 -0
- data/lib/ruby_llm/agents/version.rb +3 -1
- data/lib/ruby_llm/agents.rb +52 -0
- metadata +41 -2
- data/app/controllers/ruby_llm/agents/application_controller.rb +0 -37
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Agents
|
|
8
|
+
# Alert notification dispatcher for governance events
|
|
9
|
+
#
|
|
10
|
+
# Sends notifications to configured destinations (Slack, webhooks, custom procs)
|
|
11
|
+
# when important events occur like budget exceedance or circuit breaker activation.
|
|
12
|
+
#
|
|
13
|
+
# @example Sending an alert
|
|
14
|
+
# AlertManager.notify(:budget_soft_cap, { limit: 25.0, total: 27.5 })
|
|
15
|
+
#
|
|
16
|
+
# @see RubyLLM::Agents::Configuration
|
|
17
|
+
# @api public
|
|
18
|
+
module AlertManager
|
|
19
|
+
class << self
|
|
20
|
+
# Sends a notification to all configured destinations
|
|
21
|
+
#
|
|
22
|
+
# @param event [Symbol] The event type (e.g., :budget_soft_cap, :breaker_open)
|
|
23
|
+
# @param payload [Hash] Event-specific data
|
|
24
|
+
# @return [void]
|
|
25
|
+
def notify(event, payload)
|
|
26
|
+
config = RubyLLM::Agents.configuration
|
|
27
|
+
return unless config.alerts_enabled?
|
|
28
|
+
return unless config.alert_events.include?(event)
|
|
29
|
+
|
|
30
|
+
alerts = config.alerts
|
|
31
|
+
full_payload = payload.merge(event: event)
|
|
32
|
+
|
|
33
|
+
# Send to Slack
|
|
34
|
+
if alerts[:slack_webhook_url].present?
|
|
35
|
+
send_slack_alert(alerts[:slack_webhook_url], event, full_payload)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Send to generic webhook
|
|
39
|
+
if alerts[:webhook_url].present?
|
|
40
|
+
send_webhook_alert(alerts[:webhook_url], full_payload)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Call custom proc
|
|
44
|
+
if alerts[:custom].respond_to?(:call)
|
|
45
|
+
call_custom_alert(alerts[:custom], event, full_payload)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Emit ActiveSupport::Notification for observability
|
|
49
|
+
emit_notification(event, full_payload)
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
# Don't let alert failures break the application
|
|
52
|
+
Rails.logger.error("[RubyLLM::Agents::AlertManager] Failed to send alert: #{e.message}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Sends a Slack webhook alert
|
|
58
|
+
#
|
|
59
|
+
# @param webhook_url [String] The Slack webhook URL
|
|
60
|
+
# @param event [Symbol] The event type
|
|
61
|
+
# @param payload [Hash] The payload
|
|
62
|
+
# @return [void]
|
|
63
|
+
def send_slack_alert(webhook_url, event, payload)
|
|
64
|
+
message = format_slack_message(event, payload)
|
|
65
|
+
|
|
66
|
+
post_json(webhook_url, message)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Rails.logger.warn("[RubyLLM::Agents::AlertManager] Slack alert failed: #{e.message}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Sends a generic webhook alert
|
|
72
|
+
#
|
|
73
|
+
# @param webhook_url [String] The webhook URL
|
|
74
|
+
# @param payload [Hash] The payload
|
|
75
|
+
# @return [void]
|
|
76
|
+
def send_webhook_alert(webhook_url, payload)
|
|
77
|
+
post_json(webhook_url, payload)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
Rails.logger.warn("[RubyLLM::Agents::AlertManager] Webhook alert failed: #{e.message}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Calls a custom alert proc
|
|
83
|
+
#
|
|
84
|
+
# @param custom_proc [Proc] The custom handler
|
|
85
|
+
# @param event [Symbol] The event type
|
|
86
|
+
# @param payload [Hash] The payload
|
|
87
|
+
# @return [void]
|
|
88
|
+
def call_custom_alert(custom_proc, event, payload)
|
|
89
|
+
custom_proc.call(event, payload)
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
Rails.logger.warn("[RubyLLM::Agents::AlertManager] Custom alert failed: #{e.message}")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Emits an ActiveSupport::Notification
|
|
95
|
+
#
|
|
96
|
+
# @param event [Symbol] The event type
|
|
97
|
+
# @param payload [Hash] The payload
|
|
98
|
+
# @return [void]
|
|
99
|
+
def emit_notification(event, payload)
|
|
100
|
+
ActiveSupport::Notifications.instrument("ruby_llm_agents.alert.#{event}", payload)
|
|
101
|
+
rescue StandardError
|
|
102
|
+
# Ignore notification failures
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Formats a Slack message for the event
|
|
106
|
+
#
|
|
107
|
+
# @param event [Symbol] The event type
|
|
108
|
+
# @param payload [Hash] The payload
|
|
109
|
+
# @return [Hash] Slack message payload
|
|
110
|
+
def format_slack_message(event, payload)
|
|
111
|
+
emoji = event_emoji(event)
|
|
112
|
+
title = event_title(event)
|
|
113
|
+
color = event_color(event)
|
|
114
|
+
|
|
115
|
+
fields = payload.except(:event).map do |key, value|
|
|
116
|
+
{
|
|
117
|
+
title: key.to_s.titleize,
|
|
118
|
+
value: value.to_s,
|
|
119
|
+
short: true
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
attachments: [
|
|
125
|
+
{
|
|
126
|
+
fallback: "#{title}: #{payload.except(:event).to_json}",
|
|
127
|
+
color: color,
|
|
128
|
+
pretext: "#{emoji} *RubyLLM::Agents Alert*",
|
|
129
|
+
title: title,
|
|
130
|
+
fields: fields,
|
|
131
|
+
footer: "RubyLLM::Agents",
|
|
132
|
+
ts: Time.current.to_i
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Returns emoji for event type
|
|
139
|
+
#
|
|
140
|
+
# @param event [Symbol] The event type
|
|
141
|
+
# @return [String] Emoji
|
|
142
|
+
def event_emoji(event)
|
|
143
|
+
case event
|
|
144
|
+
when :budget_soft_cap then ":warning:"
|
|
145
|
+
when :budget_hard_cap then ":no_entry:"
|
|
146
|
+
when :breaker_open then ":rotating_light:"
|
|
147
|
+
when :agent_anomaly then ":mag:"
|
|
148
|
+
else ":bell:"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Returns title for event type
|
|
153
|
+
#
|
|
154
|
+
# @param event [Symbol] The event type
|
|
155
|
+
# @return [String] Human-readable title
|
|
156
|
+
def event_title(event)
|
|
157
|
+
case event
|
|
158
|
+
when :budget_soft_cap then "Budget Soft Cap Reached"
|
|
159
|
+
when :budget_hard_cap then "Budget Hard Cap Exceeded"
|
|
160
|
+
when :breaker_open then "Circuit Breaker Opened"
|
|
161
|
+
when :agent_anomaly then "Agent Anomaly Detected"
|
|
162
|
+
else event.to_s.titleize
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Returns color for event type
|
|
167
|
+
#
|
|
168
|
+
# @param event [Symbol] The event type
|
|
169
|
+
# @return [String] Hex color code
|
|
170
|
+
def event_color(event)
|
|
171
|
+
case event
|
|
172
|
+
when :budget_soft_cap then "#FFA500" # Orange
|
|
173
|
+
when :budget_hard_cap then "#FF0000" # Red
|
|
174
|
+
when :breaker_open then "#FF0000" # Red
|
|
175
|
+
when :agent_anomaly then "#FFA500" # Orange
|
|
176
|
+
else "#0000FF" # Blue
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Posts JSON to a URL
|
|
181
|
+
#
|
|
182
|
+
# @param url [String] The URL
|
|
183
|
+
# @param payload [Hash] The payload
|
|
184
|
+
# @return [Net::HTTPResponse]
|
|
185
|
+
def post_json(url, payload)
|
|
186
|
+
uri = URI.parse(url)
|
|
187
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
188
|
+
http.use_ssl = uri.scheme == "https"
|
|
189
|
+
http.open_timeout = 5
|
|
190
|
+
http.read_timeout = 10
|
|
191
|
+
|
|
192
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
193
|
+
request["Content-Type"] = "application/json"
|
|
194
|
+
request.body = payload.to_json
|
|
195
|
+
|
|
196
|
+
response = http.request(request)
|
|
197
|
+
|
|
198
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
199
|
+
Rails.logger.warn("[RubyLLM::Agents::AlertManager] Webhook returned #{response.code}: #{response.body}")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
response
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
# Tracks attempts during agent execution with reliability features
|
|
6
|
+
#
|
|
7
|
+
# Records details about each attempt (retries and fallbacks) during an execution,
|
|
8
|
+
# including timing, token usage, and errors. This data is stored in the execution
|
|
9
|
+
# record's `attempts` JSONB array.
|
|
10
|
+
#
|
|
11
|
+
# @example Tracking attempts
|
|
12
|
+
# tracker = AttemptTracker.new
|
|
13
|
+
# attempt = tracker.start_attempt("gpt-4o")
|
|
14
|
+
# # ... execute LLM call ...
|
|
15
|
+
# tracker.complete_attempt(attempt, success: true, response: response)
|
|
16
|
+
#
|
|
17
|
+
# @see RubyLLM::Agents::Instrumentation
|
|
18
|
+
# @api private
|
|
19
|
+
class AttemptTracker
|
|
20
|
+
attr_reader :attempts
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@attempts = []
|
|
24
|
+
@current_attempt = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Starts tracking a new attempt
|
|
28
|
+
#
|
|
29
|
+
# @param model_id [String] The model identifier being used
|
|
30
|
+
# @return [Hash] The attempt hash (pass to complete_attempt)
|
|
31
|
+
def start_attempt(model_id)
|
|
32
|
+
@current_attempt = {
|
|
33
|
+
model_id: model_id,
|
|
34
|
+
started_at: Time.current.iso8601,
|
|
35
|
+
completed_at: nil,
|
|
36
|
+
duration_ms: nil,
|
|
37
|
+
input_tokens: nil,
|
|
38
|
+
output_tokens: nil,
|
|
39
|
+
cached_tokens: 0,
|
|
40
|
+
cache_creation_tokens: 0,
|
|
41
|
+
error_class: nil,
|
|
42
|
+
error_message: nil,
|
|
43
|
+
short_circuited: false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
emit_start_notification(@current_attempt)
|
|
47
|
+
|
|
48
|
+
@current_attempt
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Completes the current attempt with results
|
|
52
|
+
#
|
|
53
|
+
# @param attempt [Hash] The attempt hash from start_attempt
|
|
54
|
+
# @param success [Boolean] Whether the attempt succeeded
|
|
55
|
+
# @param response [Object, nil] The LLM response (if successful)
|
|
56
|
+
# @param error [Exception, nil] The error (if failed)
|
|
57
|
+
# @return [Hash] The completed attempt
|
|
58
|
+
def complete_attempt(attempt, success:, response: nil, error: nil)
|
|
59
|
+
started_at = Time.parse(attempt[:started_at])
|
|
60
|
+
completed_at = Time.current
|
|
61
|
+
|
|
62
|
+
attempt[:completed_at] = completed_at.iso8601
|
|
63
|
+
attempt[:duration_ms] = ((completed_at - started_at) * 1000).round
|
|
64
|
+
|
|
65
|
+
if response
|
|
66
|
+
attempt[:input_tokens] = safe_value(response, :input_tokens)
|
|
67
|
+
attempt[:output_tokens] = safe_value(response, :output_tokens)
|
|
68
|
+
attempt[:cached_tokens] = safe_value(response, :cached_tokens, 0)
|
|
69
|
+
attempt[:cache_creation_tokens] = safe_value(response, :cache_creation_tokens, 0)
|
|
70
|
+
attempt[:model_id] = safe_value(response, :model_id) || attempt[:model_id]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if error
|
|
74
|
+
attempt[:error_class] = error.class.name
|
|
75
|
+
attempt[:error_message] = error.message.to_s.truncate(1000)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@attempts << attempt
|
|
79
|
+
@current_attempt = nil
|
|
80
|
+
|
|
81
|
+
emit_finish_notification(attempt, success)
|
|
82
|
+
|
|
83
|
+
attempt
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Records a short-circuited attempt (circuit breaker open)
|
|
87
|
+
#
|
|
88
|
+
# @param model_id [String] The model identifier
|
|
89
|
+
# @return [Hash] The recorded attempt
|
|
90
|
+
def record_short_circuit(model_id)
|
|
91
|
+
now = Time.current
|
|
92
|
+
|
|
93
|
+
attempt = {
|
|
94
|
+
model_id: model_id,
|
|
95
|
+
started_at: now.iso8601,
|
|
96
|
+
completed_at: now.iso8601,
|
|
97
|
+
duration_ms: 0,
|
|
98
|
+
input_tokens: nil,
|
|
99
|
+
output_tokens: nil,
|
|
100
|
+
cached_tokens: 0,
|
|
101
|
+
cache_creation_tokens: 0,
|
|
102
|
+
error_class: "RubyLLM::Agents::Reliability::CircuitBreakerOpenError",
|
|
103
|
+
error_message: "Circuit breaker is open",
|
|
104
|
+
short_circuited: true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@attempts << attempt
|
|
108
|
+
|
|
109
|
+
emit_short_circuit_notification(attempt)
|
|
110
|
+
|
|
111
|
+
attempt
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Finds the successful attempt (if any)
|
|
115
|
+
#
|
|
116
|
+
# @return [Hash, nil] The successful attempt or nil
|
|
117
|
+
def successful_attempt
|
|
118
|
+
@attempts.find { |a| a[:error_class].nil? && !a[:short_circuited] }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Finds the last failed attempt
|
|
122
|
+
#
|
|
123
|
+
# @return [Hash, nil] The last failed attempt or nil
|
|
124
|
+
def last_failed_attempt
|
|
125
|
+
@attempts.reverse.find { |a| a[:error_class].present? }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns all failed attempts
|
|
129
|
+
#
|
|
130
|
+
# @return [Array<Hash>] Failed attempt data
|
|
131
|
+
def failed_attempts
|
|
132
|
+
@attempts.select { |a| a[:error_class].present? }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Checks if a fallback model was used
|
|
136
|
+
#
|
|
137
|
+
# A fallback was used if multiple models were tried (more than one unique model)
|
|
138
|
+
# or if the successful model is different from the first attempted model.
|
|
139
|
+
#
|
|
140
|
+
# @return [Boolean] true if a fallback model was used
|
|
141
|
+
def used_fallback?
|
|
142
|
+
return false if @attempts.empty?
|
|
143
|
+
|
|
144
|
+
first_model = @attempts.first[:model_id]
|
|
145
|
+
successful = successful_attempt
|
|
146
|
+
|
|
147
|
+
# Fallback used if successful model differs from first attempted
|
|
148
|
+
return true if successful && successful[:model_id] != first_model
|
|
149
|
+
|
|
150
|
+
# Or if we tried multiple models (even if all failed)
|
|
151
|
+
@attempts.map { |a| a[:model_id] }.uniq.length > 1
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Returns the chosen model (from successful attempt)
|
|
155
|
+
#
|
|
156
|
+
# @return [String, nil] The model ID that succeeded
|
|
157
|
+
def chosen_model_id
|
|
158
|
+
successful_attempt&.dig(:model_id)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Calculates total tokens across all attempts
|
|
162
|
+
#
|
|
163
|
+
# @return [Integer] Total input + output tokens
|
|
164
|
+
def total_tokens
|
|
165
|
+
@attempts.sum { |a| (a[:input_tokens] || 0) + (a[:output_tokens] || 0) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Calculates total input tokens across all attempts
|
|
169
|
+
#
|
|
170
|
+
# @return [Integer] Total input tokens
|
|
171
|
+
def total_input_tokens
|
|
172
|
+
@attempts.sum { |a| a[:input_tokens] || 0 }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Calculates total output tokens across all attempts
|
|
176
|
+
#
|
|
177
|
+
# @return [Integer] Total output tokens
|
|
178
|
+
def total_output_tokens
|
|
179
|
+
@attempts.sum { |a| a[:output_tokens] || 0 }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Calculates total cached tokens across all attempts
|
|
183
|
+
#
|
|
184
|
+
# @return [Integer] Total cached tokens
|
|
185
|
+
def total_cached_tokens
|
|
186
|
+
@attempts.sum { |a| a[:cached_tokens] || 0 }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Calculates total duration across all attempts
|
|
190
|
+
#
|
|
191
|
+
# @return [Integer] Total duration in milliseconds
|
|
192
|
+
def total_duration_ms
|
|
193
|
+
@attempts.sum { |a| a[:duration_ms] || 0 }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Returns the number of attempts made
|
|
197
|
+
#
|
|
198
|
+
# @return [Integer] Number of attempts
|
|
199
|
+
def attempts_count
|
|
200
|
+
@attempts.length
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Returns the number of failed attempts
|
|
204
|
+
#
|
|
205
|
+
# @return [Integer] Number of failed attempts
|
|
206
|
+
def failed_attempts_count
|
|
207
|
+
@attempts.count { |a| a[:error_class].present? }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Returns the number of short-circuited attempts
|
|
211
|
+
#
|
|
212
|
+
# @return [Integer] Number of short-circuited attempts
|
|
213
|
+
def short_circuited_count
|
|
214
|
+
@attempts.count { |a| a[:short_circuited] }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Returns attempts as JSON-compatible array
|
|
218
|
+
#
|
|
219
|
+
# @return [Array<Hash>] Attempts data for persistence
|
|
220
|
+
def to_json_array
|
|
221
|
+
@attempts.map do |attempt|
|
|
222
|
+
attempt.transform_keys(&:to_s)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# Safely extracts a value from a response object
|
|
229
|
+
#
|
|
230
|
+
# @param response [Object] The response object
|
|
231
|
+
# @param method [Symbol] The method to call
|
|
232
|
+
# @param default [Object] Default value if method unavailable
|
|
233
|
+
# @return [Object] The extracted value or default
|
|
234
|
+
def safe_value(response, method, default = nil)
|
|
235
|
+
return default unless response.respond_to?(method)
|
|
236
|
+
response.public_send(method)
|
|
237
|
+
rescue StandardError
|
|
238
|
+
default
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Emits notification when attempt starts
|
|
242
|
+
#
|
|
243
|
+
# @param attempt [Hash] The attempt data
|
|
244
|
+
# @return [void]
|
|
245
|
+
def emit_start_notification(attempt)
|
|
246
|
+
ActiveSupport::Notifications.instrument(
|
|
247
|
+
"ruby_llm_agents.attempt.start",
|
|
248
|
+
model_id: attempt[:model_id],
|
|
249
|
+
attempt_index: @attempts.length
|
|
250
|
+
)
|
|
251
|
+
rescue StandardError
|
|
252
|
+
# Ignore notification failures
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Emits notification when attempt finishes
|
|
256
|
+
#
|
|
257
|
+
# @param attempt [Hash] The attempt data
|
|
258
|
+
# @param success [Boolean] Whether it succeeded
|
|
259
|
+
# @return [void]
|
|
260
|
+
def emit_finish_notification(attempt, success)
|
|
261
|
+
event = success ? "ruby_llm_agents.attempt.finish" : "ruby_llm_agents.attempt.error"
|
|
262
|
+
|
|
263
|
+
payload = {
|
|
264
|
+
model_id: attempt[:model_id],
|
|
265
|
+
duration_ms: attempt[:duration_ms],
|
|
266
|
+
input_tokens: attempt[:input_tokens],
|
|
267
|
+
output_tokens: attempt[:output_tokens],
|
|
268
|
+
success: success
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if !success && attempt[:error_class]
|
|
272
|
+
payload[:error_class] = attempt[:error_class]
|
|
273
|
+
payload[:error_message] = attempt[:error_message]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
ActiveSupport::Notifications.instrument(event, payload)
|
|
277
|
+
rescue StandardError
|
|
278
|
+
# Ignore notification failures
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Emits notification when attempt is short-circuited
|
|
282
|
+
#
|
|
283
|
+
# @param attempt [Hash] The attempt data
|
|
284
|
+
# @return [void]
|
|
285
|
+
def emit_short_circuit_notification(attempt)
|
|
286
|
+
ActiveSupport::Notifications.instrument(
|
|
287
|
+
"ruby_llm_agents.attempt.short_circuit",
|
|
288
|
+
model_id: attempt[:model_id]
|
|
289
|
+
)
|
|
290
|
+
rescue StandardError
|
|
291
|
+
# Ignore notification failures
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|