ruby_llm-agents 0.2.3 → 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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +273 -0
  3. data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
  4. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
  5. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
  6. data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
  7. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
  8. data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
  9. data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
  10. data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
  12. data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
  13. data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
  14. data/app/models/ruby_llm/agents/execution.rb +259 -16
  15. data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
  16. data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
  17. data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
  18. data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
  19. data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
  20. data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
  21. data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
  22. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
  23. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
  24. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
  25. data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
  26. data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
  27. data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
  28. data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
  29. data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
  30. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
  31. data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
  32. data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
  33. data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
  34. data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
  35. data/config/routes.rb +7 -0
  36. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
  37. data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
  39. data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
  41. data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
  42. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
  43. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
  44. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +139 -8
  45. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
  46. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
  47. data/lib/ruby_llm/agents/alert_manager.rb +207 -0
  48. data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
  49. data/lib/ruby_llm/agents/base.rb +580 -112
  50. data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
  51. data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
  52. data/lib/ruby_llm/agents/configuration.rb +279 -1
  53. data/lib/ruby_llm/agents/engine.rb +59 -6
  54. data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
  55. data/lib/ruby_llm/agents/inflections.rb +13 -2
  56. data/lib/ruby_llm/agents/instrumentation.rb +538 -87
  57. data/lib/ruby_llm/agents/redactor.rb +130 -0
  58. data/lib/ruby_llm/agents/reliability.rb +185 -0
  59. data/lib/ruby_llm/agents/version.rb +3 -1
  60. data/lib/ruby_llm/agents.rb +52 -0
  61. metadata +41 -2
  62. data/app/controllers/ruby_llm/agents/application_controller.rb +0 -37
@@ -0,0 +1,186 @@
1
+ <%# Collapsible version comparison UI - simplified for quick scanning %>
2
+ <% if versions.size >= 2 %>
3
+ <%
4
+ v1 = version_comparison&.dig(:v1)
5
+ v2 = version_comparison&.dig(:v2)
6
+ data = version_comparison&.dig(:data) || {}
7
+ v1_stats = data[:v1] || {}
8
+ v2_stats = data[:v2] || {}
9
+
10
+ # Calculate metrics with changes
11
+ metrics = [
12
+ { name: "Success Rate", v1: v1_stats[:success_rate] || 0, v2: v2_stats[:success_rate] || 0, format: :pct, better: :higher },
13
+ { name: "Avg Cost", v1: v1_stats[:avg_cost] || 0, v2: v2_stats[:avg_cost] || 0, format: :cost, better: :lower },
14
+ { name: "Avg Tokens", v1: v1_stats[:avg_tokens] || 0, v2: v2_stats[:avg_tokens] || 0, format: :num, better: :lower },
15
+ { name: "Avg Duration", v1: v1_stats[:avg_duration_ms] || 0, v2: v2_stats[:avg_duration_ms] || 0, format: :ms, better: :lower },
16
+ { name: "Executions", v1: v1_stats[:count] || 0, v2: v2_stats[:count] || 0, format: :num, better: :higher }
17
+ ]
18
+
19
+ # Count improvements/regressions for summary
20
+ improvements = 0
21
+ regressions = 0
22
+ metrics.each do |m|
23
+ next if m[:v1].zero? && m[:v2].zero?
24
+ if m[:better] == :higher
25
+ improvements += 1 if m[:v2] > m[:v1]
26
+ regressions += 1 if m[:v2] < m[:v1]
27
+ else
28
+ improvements += 1 if m[:v2] < m[:v1]
29
+ regressions += 1 if m[:v2] > m[:v1]
30
+ end
31
+ end
32
+ %>
33
+
34
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
35
+ <!-- Collapsible Header -->
36
+ <button
37
+ type="button"
38
+ onclick="toggleVersionComparison()"
39
+ class="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors rounded-lg"
40
+ >
41
+ <div class="flex items-center gap-3">
42
+ <svg id="version-chevron" class="w-5 h-5 text-gray-400 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
43
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
44
+ </svg>
45
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Version Comparison</h3>
46
+ <span class="text-sm text-gray-500 dark:text-gray-400">v<%= v1 %> → v<%= v2 %></span>
47
+ </div>
48
+
49
+ <!-- Quick Summary Pills -->
50
+ <div class="flex items-center gap-2">
51
+ <% if improvements > 0 %>
52
+ <span class="px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/50 rounded-full">
53
+ <%= improvements %> improved
54
+ </span>
55
+ <% end %>
56
+ <% if regressions > 0 %>
57
+ <span class="px-2 py-0.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900/50 rounded-full">
58
+ <%= regressions %> regressed
59
+ </span>
60
+ <% end %>
61
+ <% if improvements == 0 && regressions == 0 %>
62
+ <span class="px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-full">
63
+ No changes
64
+ </span>
65
+ <% end %>
66
+ </div>
67
+ </button>
68
+
69
+ <!-- Collapsible Content -->
70
+ <div id="version-comparison-content" class="hidden border-t border-gray-100 dark:border-gray-700">
71
+ <div class="p-6">
72
+ <!-- Version Selectors -->
73
+ <div class="flex items-center gap-4 mb-4">
74
+ <select id="compare-v1" onchange="updateVersionComparison()" class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg dark:text-gray-200">
75
+ <% versions.each do |ver| %>
76
+ <option value="<%= ver %>" <%= ver == v1 ? 'selected' : '' %>>v<%= ver %></option>
77
+ <% end %>
78
+ </select>
79
+ <span class="text-gray-400">→</span>
80
+ <select id="compare-v2" onchange="updateVersionComparison()" class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg dark:text-gray-200">
81
+ <% versions.each do |ver| %>
82
+ <option value="<%= ver %>" <%= ver == v2 ? 'selected' : '' %>>v<%= ver %></option>
83
+ <% end %>
84
+ </select>
85
+ </div>
86
+
87
+ <% if version_comparison && version_comparison[:data] %>
88
+ <!-- Simple Comparison Table -->
89
+ <table class="w-full text-sm">
90
+ <thead>
91
+ <tr class="text-xs text-gray-500 dark:text-gray-400 uppercase">
92
+ <th class="text-left py-2">Metric</th>
93
+ <th class="text-right py-2">v<%= v1 %></th>
94
+ <th class="text-right py-2">v<%= v2 %></th>
95
+ <th class="text-right py-2">Change</th>
96
+ </tr>
97
+ </thead>
98
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
99
+ <% metrics.each do |m| %>
100
+ <%
101
+ # Format values
102
+ case m[:format]
103
+ when :pct
104
+ f_v1 = "#{m[:v1].round(1)}%"
105
+ f_v2 = "#{m[:v2].round(1)}%"
106
+ diff = m[:v2] - m[:v1]
107
+ change = "#{diff >= 0 ? '+' : ''}#{diff.round(1)}pp"
108
+ when :cost
109
+ f_v1 = "$#{number_with_precision(m[:v1], precision: 4)}"
110
+ f_v2 = "$#{number_with_precision(m[:v2], precision: 4)}"
111
+ pct = m[:v1] > 0 ? ((m[:v2] - m[:v1]) / m[:v1] * 100) : 0
112
+ change = "#{pct >= 0 ? '+' : ''}#{pct.round(1)}%"
113
+ when :ms
114
+ f_v1 = "#{number_with_delimiter(m[:v1].round)}ms"
115
+ f_v2 = "#{number_with_delimiter(m[:v2].round)}ms"
116
+ pct = m[:v1] > 0 ? ((m[:v2] - m[:v1]) / m[:v1] * 100) : 0
117
+ change = "#{pct >= 0 ? '+' : ''}#{pct.round(1)}%"
118
+ else
119
+ f_v1 = number_with_delimiter(m[:v1].round)
120
+ f_v2 = number_with_delimiter(m[:v2].round)
121
+ pct = m[:v1] > 0 ? ((m[:v2] - m[:v1]) / m[:v1] * 100) : 0
122
+ change = "#{pct >= 0 ? '+' : ''}#{pct.round(1)}%"
123
+ end
124
+
125
+ # Determine if change is good/bad
126
+ is_better = m[:better] == :higher ? m[:v2] > m[:v1] : m[:v2] < m[:v1]
127
+ is_worse = m[:better] == :higher ? m[:v2] < m[:v1] : m[:v2] > m[:v1]
128
+ change_class = is_better ? "text-green-600 dark:text-green-400" : is_worse ? "text-red-600 dark:text-red-400" : "text-gray-500"
129
+ %>
130
+ <tr>
131
+ <td class="py-2 text-gray-700 dark:text-gray-300"><%= m[:name] %></td>
132
+ <td class="py-2 text-right text-gray-900 dark:text-gray-100 font-medium"><%= f_v1 %></td>
133
+ <td class="py-2 text-right text-gray-900 dark:text-gray-100 font-medium"><%= f_v2 %></td>
134
+ <td class="py-2 text-right font-medium <%= change_class %>"><%= change %></td>
135
+ </tr>
136
+ <% end %>
137
+ </tbody>
138
+ </table>
139
+
140
+ <p class="mt-4 text-xs text-gray-400 dark:text-gray-500 text-center">
141
+ Based on this month's data
142
+ </p>
143
+ <% else %>
144
+ <p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
145
+ No data available for selected versions
146
+ </p>
147
+ <% end %>
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ <script>
153
+ function toggleVersionComparison() {
154
+ const content = document.getElementById('version-comparison-content');
155
+ const chevron = document.getElementById('version-chevron');
156
+ content.classList.toggle('hidden');
157
+ chevron.classList.toggle('-rotate-180');
158
+ }
159
+
160
+ function updateVersionComparison() {
161
+ const v1 = document.getElementById('compare-v1').value;
162
+ const v2 = document.getElementById('compare-v2').value;
163
+ const url = new URL(window.location.href);
164
+ url.searchParams.set('compare_v1', v1);
165
+ url.searchParams.set('compare_v2', v2);
166
+ window.location.href = url.toString();
167
+ }
168
+
169
+ // Restore collapsed state from localStorage
170
+ document.addEventListener('DOMContentLoaded', function() {
171
+ const expanded = localStorage.getItem('version_comparison_expanded');
172
+ if (expanded === 'true') {
173
+ document.getElementById('version-comparison-content').classList.remove('hidden');
174
+ document.getElementById('version-chevron').classList.add('-rotate-180');
175
+ }
176
+ });
177
+
178
+ // Save state when toggling
179
+ const originalToggle = toggleVersionComparison;
180
+ toggleVersionComparison = function() {
181
+ originalToggle();
182
+ const isExpanded = !document.getElementById('version-comparison-content').classList.contains('hidden');
183
+ localStorage.setItem('version_comparison_expanded', isExpanded);
184
+ };
185
+ </script>
186
+ <% end %>
@@ -67,10 +67,58 @@
67
67
  </div>
68
68
  </div>
69
69
 
70
+ <!-- Circuit Breaker Status (if reliability enabled) -->
71
+ <% if @config && @circuit_breaker_status.present? %>
72
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-6">
73
+ <div class="flex items-center justify-between mb-3">
74
+ <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Circuit Breaker Status</h3>
75
+ <span class="text-xs text-gray-400 dark:text-gray-500">Auto-refreshes every 30s</span>
76
+ </div>
77
+
78
+ <div class="flex flex-wrap gap-3">
79
+ <% @circuit_breaker_status.each do |model_id, status| %>
80
+ <div class="flex items-center gap-2 px-3 py-2 rounded-lg border <%= status[:open] ? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800' : 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' %>">
81
+ <% if status[:open] %>
82
+ <!-- Open/Tripped indicator -->
83
+ <span class="relative flex h-3 w-3">
84
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
85
+ <span class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
86
+ </span>
87
+ <span class="text-sm font-medium text-red-700 dark:text-red-300"><%= model_id %></span>
88
+ <span class="text-xs text-red-500 dark:text-red-400 ml-1">OPEN</span>
89
+ <% if status[:cooldown_remaining] %>
90
+ <span class="text-xs text-red-400 dark:text-red-500 ml-1">
91
+ (resets in <%= status[:cooldown_remaining] %>s)
92
+ </span>
93
+ <% end %>
94
+ <% else %>
95
+ <!-- Closed/Healthy indicator -->
96
+ <span class="inline-flex rounded-full h-3 w-3 bg-green-500"></span>
97
+ <span class="text-sm font-medium text-green-700 dark:text-green-300"><%= model_id %></span>
98
+ <span class="text-xs text-green-500 dark:text-green-400 ml-1">OK</span>
99
+ <% if status[:failure_count] && status[:failure_count] > 0 %>
100
+ <span class="text-xs text-yellow-500 dark:text-yellow-400 ml-1">
101
+ (<%= status[:failure_count] %>/<%= status[:threshold] %> failures)
102
+ </span>
103
+ <% end %>
104
+ <% end %>
105
+ </div>
106
+ <% end %>
107
+ </div>
108
+
109
+ <% if @circuit_breaker_status.values.any? { |s| s[:open] } %>
110
+ <p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
111
+ Open circuit breakers will skip the model and try fallbacks (if configured).
112
+ They automatically reset after the cooldown period.
113
+ </p>
114
+ <% end %>
115
+ </div>
116
+ <% end %>
117
+
70
118
  <!-- Stats Grid -->
71
119
  <% success_rate = @stats[:success_rate] || 0 %>
72
120
  <% success_rate_color = success_rate >= 95 ? 'text-green-600' : success_rate >= 80 ? 'text-yellow-600' : 'text-red-600' %>
73
- <div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
121
+ <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
74
122
  <%= render "rubyllm/agents/shared/stat_card",
75
123
  title: "Executions",
76
124
  value: number_with_delimiter(@stats[:count]),
@@ -105,6 +153,12 @@
105
153
  value: "#{number_with_delimiter(@stats[:avg_duration_ms] || 0)} ms",
106
154
  icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z",
107
155
  icon_color: "text-purple-500" %>
156
+
157
+ <%= render "rubyllm/agents/shared/stat_card",
158
+ title: "Cache Hit Rate",
159
+ value: "#{@cache_hit_rate}%",
160
+ icon: "M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4",
161
+ icon_color: "text-purple-500" %>
108
162
  </div>
109
163
 
110
164
  <!-- Charts Section -->
@@ -122,7 +176,11 @@
122
176
  <%= area_chart [
123
177
  { name: "Success", data: success_data },
124
178
  { name: "Failed", data: failed_data }
125
- ], colors: ["#10B981", "#EF4444"], stacked: true, library: { maintainAspectRatio: false } %>
179
+ ], colors: ["#10B981", "#EF4444"], stacked: true, library: {
180
+ yAxis: { min: 0 },
181
+ legend: { align: "center", verticalAlign: "bottom" },
182
+ plotOptions: { area: { stacking: "normal" } }
183
+ } %>
126
184
  </div>
127
185
  </div>
128
186
 
@@ -133,7 +191,10 @@
133
191
  <div id="cost-chart" style="height: 250px;">
134
192
  <% cost_data = @trend_data.map { |d| [d[:date].strftime("%b %d"), d[:total_cost].to_f.round(4)] }.to_h %>
135
193
 
136
- <%= line_chart cost_data, colors: ["#10B981"], prefix: "$", library: { maintainAspectRatio: false, plugins: { legend: { display: false } } } %>
194
+ <%= line_chart cost_data, colors: ["#10B981"], prefix: "$", library: {
195
+ yAxis: { min: 0 },
196
+ legend: { enabled: false }
197
+ } %>
137
198
  </div>
138
199
  </div>
139
200
  </div>
@@ -169,12 +230,52 @@
169
230
  </div>
170
231
  </div>
171
232
 
233
+ <!-- Finish Reason Distribution -->
234
+ <% if @finish_reason_distribution.present? && @finish_reason_distribution.any? %>
235
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-6">
236
+ <div class="flex items-center justify-between">
237
+ <p class="text-sm text-gray-500 dark:text-gray-400 uppercase">Finish Reasons</p>
238
+
239
+ <div class="flex flex-wrap gap-4">
240
+ <% finish_colors = {
241
+ 'stop' => '#10B981',
242
+ 'length' => '#F59E0B',
243
+ 'content_filter' => '#EF4444',
244
+ 'tool_calls' => '#3B82F6',
245
+ nil => '#6B7280'
246
+ } %>
247
+
248
+ <% @finish_reason_distribution.each do |reason, count| %>
249
+ <div class="flex items-center">
250
+ <span
251
+ class="w-2 h-2 rounded-full mr-1.5"
252
+ style="background-color: <%= finish_colors[reason] || '#6B7280' %>"
253
+ ></span>
254
+
255
+ <span class="text-sm text-gray-700 dark:text-gray-300"><%= reason || 'unknown' %></span>
256
+
257
+ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 ml-1">
258
+ (<%= number_with_delimiter(count) %>)
259
+ </span>
260
+ </div>
261
+ <% end %>
262
+ </div>
263
+ </div>
264
+ </div>
265
+ <% end %>
266
+
267
+ <!-- Version Comparison -->
268
+ <%= render partial: "rubyllm/agents/agents/version_comparison",
269
+ locals: { versions: @versions, version_comparison: @version_comparison } %>
270
+
172
271
  <% if @config %>
173
272
  <!-- Configuration -->
174
273
  <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
175
274
  <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Configuration</h3>
176
275
 
177
- <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
276
+ <!-- Basic Configuration -->
277
+ <p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">Basic</p>
278
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
178
279
  <div>
179
280
  <p class="text-sm text-gray-500 dark:text-gray-400">Model</p>
180
281
  <p class="font-medium text-gray-900 dark:text-gray-100"><%= @config[:model] %></p>
@@ -192,22 +293,144 @@
192
293
 
193
294
  <div>
194
295
  <p class="text-sm text-gray-500 dark:text-gray-400">Cache</p>
195
-
196
296
  <p class="font-medium text-gray-900 dark:text-gray-100">
197
297
  <% if @config[:cache_enabled] %>
198
- Enabled (
199
- <%= @config[:cache_ttl].inspect %>
200
- )
298
+ Enabled (<%= @config[:cache_ttl].inspect %>)
201
299
  <% else %>
202
- Disabled
300
+ <span class="text-gray-400 dark:text-gray-500">Disabled</span>
203
301
  <% end %>
204
302
  </p>
205
303
  </div>
206
304
  </div>
207
305
 
306
+ <!-- Reliability Configuration -->
307
+ <%
308
+ retries_config = @config[:retries] || {}
309
+ has_retries = (retries_config[:max] || 0) > 0
310
+ has_fallbacks = @config[:fallback_models].present? && @config[:fallback_models].any?
311
+ has_total_timeout = @config[:total_timeout].present?
312
+ has_circuit_breaker = @config[:circuit_breaker].present?
313
+ has_any_reliability = has_retries || has_fallbacks || has_total_timeout || has_circuit_breaker
314
+ %>
315
+ <div class="border-t border-gray-100 dark:border-gray-700 pt-4 mb-6">
316
+ <p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">Reliability</p>
317
+
318
+ <% if has_any_reliability %>
319
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
320
+ <!-- Retries -->
321
+ <div class="flex items-start gap-3 p-3 rounded-lg <%= has_retries ? 'bg-green-50 dark:bg-green-900/20' : 'bg-gray-50 dark:bg-gray-700/50' %>">
322
+ <div class="flex-shrink-0 mt-0.5">
323
+ <% if has_retries %>
324
+ <svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
325
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
326
+ </svg>
327
+ <% else %>
328
+ <svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
329
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
330
+ </svg>
331
+ <% end %>
332
+ </div>
333
+ <div>
334
+ <p class="text-sm font-medium text-gray-900 dark:text-gray-100">Retries</p>
335
+ <% if has_retries %>
336
+ <p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
337
+ Max: <%= retries_config[:max] %> &middot;
338
+ Backoff: <%= retries_config[:backoff] %> &middot;
339
+ Base: <%= retries_config[:base] %>s &middot;
340
+ Max delay: <%= retries_config[:max_delay] %>s
341
+ </p>
342
+ <% else %>
343
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Not configured</p>
344
+ <% end %>
345
+ </div>
346
+ </div>
347
+
348
+ <!-- Fallback Models -->
349
+ <div class="flex items-start gap-3 p-3 rounded-lg <%= has_fallbacks ? 'bg-green-50 dark:bg-green-900/20' : 'bg-gray-50 dark:bg-gray-700/50' %>">
350
+ <div class="flex-shrink-0 mt-0.5">
351
+ <% if has_fallbacks %>
352
+ <svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
353
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
354
+ </svg>
355
+ <% else %>
356
+ <svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
357
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
358
+ </svg>
359
+ <% end %>
360
+ </div>
361
+ <div>
362
+ <p class="text-sm font-medium text-gray-900 dark:text-gray-100">Fallback Models</p>
363
+ <% if has_fallbacks %>
364
+ <p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
365
+ <%= @config[:fallback_models].join(" → ") %>
366
+ </p>
367
+ <% else %>
368
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Not configured</p>
369
+ <% end %>
370
+ </div>
371
+ </div>
372
+
373
+ <!-- Total Timeout -->
374
+ <div class="flex items-start gap-3 p-3 rounded-lg <%= has_total_timeout ? 'bg-green-50 dark:bg-green-900/20' : 'bg-gray-50 dark:bg-gray-700/50' %>">
375
+ <div class="flex-shrink-0 mt-0.5">
376
+ <% if has_total_timeout %>
377
+ <svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
378
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
379
+ </svg>
380
+ <% else %>
381
+ <svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
382
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
383
+ </svg>
384
+ <% end %>
385
+ </div>
386
+ <div>
387
+ <p class="text-sm font-medium text-gray-900 dark:text-gray-100">Total Timeout</p>
388
+ <% if has_total_timeout %>
389
+ <p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
390
+ <%= @config[:total_timeout] %> seconds across all attempts
391
+ </p>
392
+ <% else %>
393
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Not configured</p>
394
+ <% end %>
395
+ </div>
396
+ </div>
397
+
398
+ <!-- Circuit Breaker -->
399
+ <div class="flex items-start gap-3 p-3 rounded-lg <%= has_circuit_breaker ? 'bg-green-50 dark:bg-green-900/20' : 'bg-gray-50 dark:bg-gray-700/50' %>">
400
+ <div class="flex-shrink-0 mt-0.5">
401
+ <% if has_circuit_breaker %>
402
+ <svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
403
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
404
+ </svg>
405
+ <% else %>
406
+ <svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
407
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
408
+ </svg>
409
+ <% end %>
410
+ </div>
411
+ <div>
412
+ <p class="text-sm font-medium text-gray-900 dark:text-gray-100">Circuit Breaker</p>
413
+ <% if has_circuit_breaker %>
414
+ <% cb = @config[:circuit_breaker] %>
415
+ <p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
416
+ Opens after <%= cb[:errors] %> errors within <%= cb[:within] %>s &middot;
417
+ Cooldown: <%= cb[:cooldown] %>s
418
+ </p>
419
+ <% else %>
420
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Not configured</p>
421
+ <% end %>
422
+ </div>
423
+ </div>
424
+ </div>
425
+ <% else %>
426
+ <p class="text-sm text-gray-400 dark:text-gray-500">No reliability features configured</p>
427
+ <% end %>
428
+ </div>
429
+
430
+ <!-- Parameters -->
208
431
  <% if @config[:params].present? && @config[:params].any? %>
209
432
  <div class="border-t border-gray-100 dark:border-gray-700 pt-4">
210
- <p class="text-sm text-gray-500 dark:text-gray-400 mb-2">Parameters</p>
433
+ <p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">Parameters</p>
211
434
 
212
435
  <div class="space-y-2">
213
436
  <% @config[:params].each do |name, opts| %>
@@ -0,0 +1,62 @@
1
+ <% if critical_alerts.any? %>
2
+ <div id="action-center" class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
3
+ <div class="flex items-center mb-3">
4
+ <svg class="w-5 h-5 text-red-600 dark:text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
5
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
6
+ </svg>
7
+ <h3 class="font-semibold text-red-800 dark:text-red-200">Action Required</h3>
8
+ </div>
9
+
10
+ <div class="space-y-2">
11
+ <% critical_alerts.each do |alert| %>
12
+ <div class="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg px-4 py-3 shadow-sm">
13
+ <% case alert[:type] %>
14
+ <% when :breaker %>
15
+ <div class="flex items-center">
16
+ <span class="w-2 h-2 bg-orange-500 rounded-full mr-3"></span>
17
+ <div>
18
+ <p class="text-sm font-medium text-gray-900 dark:text-gray-100">
19
+ Circuit breaker open: <%= alert[:data][:agent_type].gsub(/Agent$/, '') %>
20
+ </p>
21
+ <p class="text-xs text-gray-500 dark:text-gray-400">
22
+ <%= alert[:data][:model_id] %> &middot; <%= alert[:data][:failure_count] %>/<%= alert[:data][:threshold] %> failures
23
+ </p>
24
+ </div>
25
+ </div>
26
+ <span class="text-xs text-orange-600 dark:text-orange-400 font-medium">
27
+ <%= alert[:data][:cooldown_remaining] %>s remaining
28
+ </span>
29
+
30
+ <% when :budget_breach %>
31
+ <div class="flex items-center">
32
+ <span class="w-2 h-2 bg-red-500 rounded-full mr-3"></span>
33
+ <div>
34
+ <p class="text-sm font-medium text-gray-900 dark:text-gray-100">
35
+ <%= alert[:data][:period].to_s.capitalize %> budget exceeded
36
+ </p>
37
+ <p class="text-xs text-gray-500 dark:text-gray-400">
38
+ $<%= number_with_precision(alert[:data][:current], precision: 2) %> / $<%= number_with_precision(alert[:data][:limit], precision: 2) %>
39
+ </p>
40
+ </div>
41
+ </div>
42
+ <%= link_to "Adjust", ruby_llm_agents.settings_path, class: "text-xs text-red-600 dark:text-red-400 hover:underline font-medium" %>
43
+
44
+ <% when :error_spike %>
45
+ <div class="flex items-center">
46
+ <span class="w-2 h-2 bg-red-500 rounded-full mr-3 animate-pulse"></span>
47
+ <div>
48
+ <p class="text-sm font-medium text-gray-900 dark:text-gray-100">
49
+ Error spike detected
50
+ </p>
51
+ <p class="text-xs text-gray-500 dark:text-gray-400">
52
+ <%= alert[:data][:count] %> errors in last 15 minutes
53
+ </p>
54
+ </div>
55
+ </div>
56
+ <%= link_to "View failures", ruby_llm_agents.executions_path(status: "error"), class: "text-xs text-red-600 dark:text-red-400 hover:underline font-medium" %>
57
+ <% end %>
58
+ </div>
59
+ <% end %>
60
+ </div>
61
+ </div>
62
+ <% end %>
@@ -0,0 +1,62 @@
1
+ <%# Alerts feed for budget and breaker events %>
2
+ <% if recent_alerts.any? %>
3
+ <div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 p-4 mb-6">
4
+ <div class="flex items-center justify-between mb-3">
5
+ <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Recent Alerts</h3>
6
+ <span class="text-xs text-gray-500 dark:text-gray-400">
7
+ Last <%= recent_alerts.size %> events
8
+ </span>
9
+ </div>
10
+
11
+ <div class="space-y-2 max-h-48 overflow-y-auto">
12
+ <% recent_alerts.each do |alert| %>
13
+ <%
14
+ icon_color = case alert[:type].to_s
15
+ when /budget/ then "text-amber-500"
16
+ when /breaker_open/ then "text-red-500"
17
+ when /breaker_closed/ then "text-green-500"
18
+ else "text-blue-500"
19
+ end
20
+
21
+ bg_color = case alert[:type].to_s
22
+ when /budget/ then "bg-amber-50 dark:bg-amber-900/20"
23
+ when /breaker_open/ then "bg-red-50 dark:bg-red-900/20"
24
+ when /breaker_closed/ then "bg-green-50 dark:bg-green-900/20"
25
+ else "bg-blue-50 dark:bg-blue-900/20"
26
+ end
27
+
28
+ icon_path = case alert[:type].to_s
29
+ when /budget/
30
+ "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
31
+ when /breaker/
32
+ "M13 10V3L4 14h7v7l9-11h-7z"
33
+ else
34
+ "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
35
+ end
36
+ %>
37
+ <div class="flex items-start gap-3 p-2 rounded-lg <%= bg_color %>">
38
+ <svg class="w-4 h-4 mt-0.5 <%= icon_color %> flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
39
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="<%= icon_path %>"/>
40
+ </svg>
41
+ <div class="flex-1 min-w-0">
42
+ <p class="text-sm text-gray-700 dark:text-gray-300">
43
+ <%= alert[:message] || alert[:type].to_s.humanize %>
44
+ </p>
45
+ <div class="flex items-center gap-2 mt-0.5">
46
+ <% if alert[:agent_type] %>
47
+ <span class="text-xs text-gray-500 dark:text-gray-400">
48
+ <%= alert[:agent_type].gsub(/Agent$/, '') %>
49
+ </span>
50
+ <% end %>
51
+ <% if alert[:timestamp] %>
52
+ <span class="text-xs text-gray-400 dark:text-gray-500">
53
+ <%= alert[:timestamp].is_a?(Time) ? time_ago_in_words(alert[:timestamp]) + " ago" : alert[:timestamp] %>
54
+ </span>
55
+ <% end %>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ <% end %>
60
+ </div>
61
+ </div>
62
+ <% end %>
@@ -0,0 +1,47 @@
1
+ <%# Circuit breaker status strip %>
2
+ <div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 p-4 mb-6">
3
+ <div class="flex items-center justify-between mb-3">
4
+ <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Circuit Breakers</h3>
5
+ <% if open_breakers.any? %>
6
+ <span class="flex items-center text-xs text-red-600 dark:text-red-400">
7
+ <span class="w-2 h-2 bg-red-500 rounded-full mr-1 animate-pulse"></span>
8
+ <%= open_breakers.size %> open
9
+ </span>
10
+ <% else %>
11
+ <span class="flex items-center text-xs text-green-600 dark:text-green-400">
12
+ <span class="w-2 h-2 bg-green-500 rounded-full mr-1"></span>
13
+ All healthy
14
+ </span>
15
+ <% end %>
16
+ </div>
17
+
18
+ <% if open_breakers.any? %>
19
+ <div class="flex flex-wrap gap-2">
20
+ <% open_breakers.each do |breaker| %>
21
+ <%= link_to ruby_llm_agents.agent_path(breaker[:agent_type]),
22
+ class: "inline-flex items-center px-3 py-1.5 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-full text-sm hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors" do %>
23
+ <span class="w-2 h-2 bg-red-500 rounded-full mr-2 animate-pulse"></span>
24
+ <span class="text-red-700 dark:text-red-300 font-medium">
25
+ <%= breaker[:agent_type].gsub(/Agent$/, '') %>
26
+ </span>
27
+ <span class="text-red-500 dark:text-red-400 mx-1">/</span>
28
+ <span class="text-red-600 dark:text-red-300 text-xs">
29
+ <%= breaker[:model_id].split('/').last %>
30
+ </span>
31
+ <% if breaker[:cooldown_remaining] && breaker[:cooldown_remaining] > 0 %>
32
+ <span class="ml-2 text-xs text-red-500 dark:text-red-400 tabular-nums" data-cooldown="<%= breaker[:cooldown_remaining] %>">
33
+ <%= breaker[:cooldown_remaining] %>s
34
+ </span>
35
+ <% end %>
36
+ <% end %>
37
+ <% end %>
38
+ </div>
39
+ <% else %>
40
+ <div class="flex items-center justify-center py-2 text-gray-500 dark:text-gray-400">
41
+ <svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
42
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
43
+ </svg>
44
+ <span class="text-sm">All circuit breakers are closed</span>
45
+ </div>
46
+ <% end %>
47
+ </div>