ruby_llm-agents 0.3.3 → 0.3.4

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +68 -4
  3. data/app/models/ruby_llm/agents/execution/analytics.rb +114 -13
  4. data/app/models/ruby_llm/agents/execution.rb +19 -58
  5. data/app/views/layouts/rubyllm/agents/application.html.erb +92 -350
  6. data/app/views/rubyllm/agents/agents/show.html.erb +331 -385
  7. data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +46 -0
  8. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -90
  9. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +79 -5
  10. data/app/views/rubyllm/agents/dashboard/_top_errors.html.erb +49 -0
  11. data/app/views/rubyllm/agents/dashboard/index.html.erb +76 -121
  12. data/app/views/rubyllm/agents/executions/show.html.erb +134 -85
  13. data/app/views/rubyllm/agents/settings/show.html.erb +1 -1
  14. data/app/views/rubyllm/agents/shared/_breadcrumbs.html.erb +48 -0
  15. data/app/views/rubyllm/agents/shared/_nav_link.html.erb +27 -0
  16. data/config/routes.rb +2 -0
  17. data/lib/ruby_llm/agents/base/caching.rb +43 -0
  18. data/lib/ruby_llm/agents/base/cost_calculation.rb +103 -0
  19. data/lib/ruby_llm/agents/base/dsl.rb +261 -0
  20. data/lib/ruby_llm/agents/base/execution.rb +206 -0
  21. data/lib/ruby_llm/agents/base/reliability_execution.rb +131 -0
  22. data/lib/ruby_llm/agents/base/response_building.rb +86 -0
  23. data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
  24. data/lib/ruby_llm/agents/base.rb +15 -805
  25. data/lib/ruby_llm/agents/version.rb +1 -1
  26. metadata +12 -20
  27. data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
  28. data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
  29. data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
  30. data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
  31. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
@@ -1,24 +1,28 @@
1
1
  <div id="execution-detail" data-execution-id="<%= @execution.id %>" data-status="<%= @execution.status %>">
2
- <div class="mb-6">
3
- <%= link_to ruby_llm_agents.executions_path, class: "inline-flex items-center text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200" do %>
4
- <svg
5
- class="w-4 h-4 mr-1"
6
- fill="none"
7
- stroke="currentColor"
8
- viewBox="0 0 24 24"
9
- >
10
- <path
11
- stroke-linecap="round"
12
- stroke-linejoin="round"
13
- stroke-width="2"
14
- d="M10 19l-7-7m0 0l7-7m-7 7h18"
15
- />
16
- </svg>
17
- Back to Executions
18
- <% end %>
19
- </div>
2
+ <%= render "rubyllm/agents/shared/breadcrumbs", items: [
3
+ { label: "Dashboard", path: ruby_llm_agents.root_path },
4
+ { label: "Executions", path: ruby_llm_agents.executions_path },
5
+ { label: "##{@execution.id}" }
6
+ ] %>
20
7
 
21
8
  <!-- Header -->
9
+ <%
10
+ # Collect secondary badges
11
+ secondary_badges = []
12
+ secondary_badges << { label: "Stream", color: "cyan" } if @execution.streaming?
13
+ secondary_badges << { label: "Cached", color: "purple" } if @execution.cache_hit
14
+ if @execution.finish_reason.present?
15
+ finish_color = case @execution.finish_reason
16
+ when 'stop' then 'green'
17
+ when 'length' then 'yellow'
18
+ when 'content_filter' then 'red'
19
+ when 'tool_calls' then 'blue'
20
+ else 'gray'
21
+ end
22
+ secondary_badges << { label: @execution.finish_reason, color: finish_color }
23
+ end
24
+ secondary_badges << { label: "Rate Limited", color: "orange" } if @execution.respond_to?(:rate_limited?) && @execution.rate_limited?
25
+ %>
22
26
  <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 sm:p-5 mb-6">
23
27
  <!-- Desktop: Row 1 - Agent name + badges + buttons + date -->
24
28
  <div class="hidden sm:flex sm:items-center sm:justify-between gap-4">
@@ -27,20 +31,44 @@
27
31
  <%= @execution.agent_type.gsub(/Agent$/, '') %>
28
32
  </h2>
29
33
  <%= render "rubyllm/agents/shared/status_badge", status: @execution.status, size: :md %>
30
- <% if @execution.streaming? %>
31
- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-cyan-100 dark:bg-cyan-900/50 text-cyan-800 dark:text-cyan-300">Stream</span>
32
- <% end %>
33
- <% if @execution.cache_hit %>
34
- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300">Cached</span>
35
- <% end %>
36
- <% if @execution.finish_reason.present? %>
37
- <% finish_colors = {
38
- 'stop' => 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300',
39
- 'length' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300',
40
- 'content_filter' => 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300',
41
- 'tool_calls' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300'
42
- } %>
43
- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium <%= finish_colors[@execution.finish_reason] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>"><%= @execution.finish_reason %></span>
34
+ <% if secondary_badges.any? %>
35
+ <div class="relative" x-data="{ showDetails: false }">
36
+ <button
37
+ type="button"
38
+ @mouseenter="showDetails = true"
39
+ @mouseleave="showDetails = false"
40
+ class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
41
+ >
42
+ +<%= secondary_badges.size %>
43
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
44
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
45
+ </svg>
46
+ </button>
47
+ <div
48
+ x-show="showDetails"
49
+ x-cloak
50
+ x-transition:enter="transition ease-out duration-100"
51
+ x-transition:enter-start="opacity-0 scale-95"
52
+ x-transition:enter-end="opacity-100 scale-100"
53
+ class="absolute left-0 mt-1 z-10 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-2 min-w-max"
54
+ >
55
+ <div class="flex flex-wrap gap-1.5">
56
+ <% secondary_badges.each do |badge| %>
57
+ <% badge_classes = case badge[:color]
58
+ when 'cyan' then 'bg-cyan-100 dark:bg-cyan-900/50 text-cyan-800 dark:text-cyan-300'
59
+ when 'purple' then 'bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300'
60
+ when 'green' then 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300'
61
+ when 'yellow' then 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-300'
62
+ when 'red' then 'bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-300'
63
+ when 'blue' then 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300'
64
+ when 'orange' then 'bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-300'
65
+ else 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300'
66
+ end %>
67
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium <%= badge_classes %>"><%= badge[:label] %></span>
68
+ <% end %>
69
+ </div>
70
+ </div>
71
+ </div>
44
72
  <% end %>
45
73
  </div>
46
74
  <div class="flex items-center gap-3 flex-shrink-0">
@@ -88,20 +116,18 @@
88
116
  </h2>
89
117
  <div class="flex flex-wrap items-center gap-2 mt-2">
90
118
  <%= render "rubyllm/agents/shared/status_badge", status: @execution.status, size: :md %>
91
- <% if @execution.streaming? %>
92
- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-cyan-100 dark:bg-cyan-900/50 text-cyan-800 dark:text-cyan-300">Stream</span>
93
- <% end %>
94
- <% if @execution.cache_hit %>
95
- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300">Cached</span>
96
- <% end %>
97
- <% if @execution.finish_reason.present? %>
98
- <% finish_colors = {
99
- 'stop' => 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300',
100
- 'length' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300',
101
- 'content_filter' => 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300',
102
- 'tool_calls' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300'
103
- } %>
104
- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium <%= finish_colors[@execution.finish_reason] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>"><%= @execution.finish_reason %></span>
119
+ <% secondary_badges.each do |badge| %>
120
+ <% badge_classes = case badge[:color]
121
+ when 'cyan' then 'bg-cyan-100 dark:bg-cyan-900/50 text-cyan-800 dark:text-cyan-300'
122
+ when 'purple' then 'bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300'
123
+ when 'green' then 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300'
124
+ when 'yellow' then 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-300'
125
+ when 'red' then 'bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-300'
126
+ when 'blue' then 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300'
127
+ when 'orange' then 'bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-300'
128
+ else 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300'
129
+ end %>
130
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium <%= badge_classes %>"><%= badge[:label] %></span>
105
131
  <% end %>
106
132
  </div>
107
133
  <p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
@@ -484,7 +510,8 @@
484
510
 
485
511
  <!-- Tool Calls -->
486
512
  <% if @execution.tool_calls.present? && @execution.tool_calls.any? %>
487
- <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 mb-6">
513
+ <% tool_call_count = @execution.tool_calls.size %>
514
+ <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 mb-6" x-data="{ expanded: <%= tool_call_count <= 3 %> }">
488
515
  <div class="flex items-center justify-between mb-4">
489
516
  <div class="flex items-center gap-2">
490
517
  <svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -493,25 +520,32 @@
493
520
  </svg>
494
521
  <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Tool Calls</h3>
495
522
  <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300">
496
- <%= @execution.tool_calls.size %>
523
+ <%= tool_call_count %>
497
524
  </span>
498
525
  </div>
499
- <button
500
- type="button"
501
- data-copy-json="<%= Base64.strict_encode64(JSON.pretty_generate(@execution.tool_calls)) %>"
502
- class="copy-json-btn inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
503
- >
504
- <svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
505
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
506
- </svg>
507
- <svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
508
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
509
- </svg>
510
- <span>Copy</span>
511
- </button>
526
+ <div class="flex items-center gap-2">
527
+ <button
528
+ type="button"
529
+ data-copy-json="<%= Base64.strict_encode64(JSON.pretty_generate(@execution.tool_calls)) %>"
530
+ class="copy-json-btn inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
531
+ >
532
+ <svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
533
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
534
+ </svg>
535
+ <svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
536
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
537
+ </svg>
538
+ <span>Copy</span>
539
+ </button>
540
+ <% if tool_call_count > 3 %>
541
+ <button type="button" @click="expanded = !expanded" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
542
+ <span x-text="expanded ? 'Collapse' : 'Expand'">Expand</span>
543
+ </button>
544
+ <% end %>
545
+ </div>
512
546
  </div>
513
547
 
514
- <div class="space-y-4">
548
+ <div class="space-y-4" x-show="expanded" <%= tool_call_count > 3 ? 'x-cloak' : '' %>>
515
549
  <% @execution.tool_calls.each_with_index do |tool_call, index| %>
516
550
  <%
517
551
  # Handle both symbol and string keys
@@ -554,26 +588,36 @@
554
588
 
555
589
  <!-- Metadata -->
556
590
  <% if @execution.metadata.present? && @execution.metadata.any? %>
557
- <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 mb-6">
558
- <div class="flex items-center justify-between mb-4">
559
- <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Metadata</h3>
560
- <button
561
- type="button"
562
- data-copy-json="<%= Base64.strict_encode64(JSON.pretty_generate(redact_for_display(@execution.metadata))) %>"
563
- data-copy-json-original="<%= Base64.strict_encode64(JSON.pretty_generate(@execution.metadata)) %>"
564
- class="copy-json-btn inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
565
- >
566
- <svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
567
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
568
- </svg>
569
- <svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
570
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
571
- </svg>
572
- <span>Copy</span>
573
- </button>
591
+ <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 mb-6" x-data="{ expanded: false }">
592
+ <div class="flex items-center justify-between">
593
+ <div class="flex items-center gap-2">
594
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Metadata</h3>
595
+ <span class="text-xs text-gray-400 dark:text-gray-500">(<%= @execution.metadata.keys.count %> keys)</span>
596
+ </div>
597
+ <div class="flex items-center gap-2">
598
+ <button
599
+ type="button"
600
+ data-copy-json="<%= Base64.strict_encode64(JSON.pretty_generate(redact_for_display(@execution.metadata))) %>"
601
+ data-copy-json-original="<%= Base64.strict_encode64(JSON.pretty_generate(@execution.metadata)) %>"
602
+ class="copy-json-btn inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
603
+ >
604
+ <svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
605
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
606
+ </svg>
607
+ <svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
608
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
609
+ </svg>
610
+ <span>Copy</span>
611
+ </button>
612
+ <button type="button" @click="expanded = !expanded" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
613
+ <span x-text="expanded ? 'Collapse' : 'Expand'">Expand</span>
614
+ </button>
615
+ </div>
616
+ </div>
617
+ <div x-show="expanded" x-cloak class="mt-4">
618
+ <pre id="metadata-masked" class="maskable-content bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-lg p-4 text-sm overflow-x-auto font-mono"><%= highlight_json_redacted(@execution.metadata) %></pre>
619
+ <pre id="metadata-original" class="maskable-content hidden bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-lg p-4 text-sm overflow-x-auto font-mono"><%= highlight_json(@execution.metadata) %></pre>
574
620
  </div>
575
- <pre id="metadata-masked" class="maskable-content bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-lg p-4 text-sm overflow-x-auto font-mono"><%= highlight_json_redacted(@execution.metadata) %></pre>
576
- <pre id="metadata-original" class="maskable-content hidden bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-lg p-4 text-sm overflow-x-auto font-mono"><%= highlight_json(@execution.metadata) %></pre>
577
621
  </div>
578
622
  <% end %>
579
623
 
@@ -618,6 +662,7 @@
618
662
  <span id="system-prompt-toggle">Expand</span>
619
663
  </button>
620
664
  </div>
665
+ <p id="system-prompt-preview" class="text-sm text-gray-600 dark:text-gray-300 font-mono bg-gray-50 dark:bg-gray-900 rounded-lg p-3 truncate"><%= @execution.system_prompt.truncate(150) %></p>
621
666
  <pre id="system-prompt-content" class="hidden bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-lg p-4 text-sm overflow-x-auto max-h-96 font-mono whitespace-pre-wrap"><%= @execution.system_prompt %></pre>
622
667
  </div>
623
668
  <% end %>
@@ -631,6 +676,7 @@
631
676
  <span id="user-prompt-toggle">Expand</span>
632
677
  </button>
633
678
  </div>
679
+ <p id="user-prompt-preview" class="text-sm text-gray-600 dark:text-gray-300 font-mono bg-gray-50 dark:bg-gray-900 rounded-lg p-3 truncate"><%= @execution.user_prompt.truncate(150) %></p>
634
680
  <pre id="user-prompt-content" class="hidden bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-lg p-4 text-sm overflow-x-auto max-h-96 font-mono whitespace-pre-wrap"><%= @execution.user_prompt %></pre>
635
681
  </div>
636
682
  <% end %>
@@ -867,9 +913,12 @@
867
913
  // Prompt toggle function
868
914
  function togglePrompt(type) {
869
915
  const content = document.getElementById(type + '-prompt-content');
916
+ const preview = document.getElementById(type + '-prompt-preview');
870
917
  const toggle = document.getElementById(type + '-prompt-toggle');
871
- content.classList.toggle('hidden');
872
- toggle.textContent = content.classList.contains('hidden') ? 'Expand' : 'Collapse';
918
+ const isHidden = content.classList.contains('hidden');
919
+ content.classList.toggle('hidden', !isHidden);
920
+ if (preview) preview.classList.toggle('hidden', isHidden);
921
+ toggle.textContent = isHidden ? 'Collapse' : 'Expand';
873
922
  }
874
923
 
875
924
  // Masking state (persisted in localStorage)
@@ -972,8 +1021,8 @@
972
1021
  }
973
1022
  });
974
1023
 
975
- // Diagnostics panel toggle
976
- let diagnosticsExpanded = localStorage.getItem('ruby_llm_agents_diagnostics_expanded') === 'true';
1024
+ // Diagnostics panel toggle (default to expanded)
1025
+ let diagnosticsExpanded = localStorage.getItem('ruby_llm_agents_diagnostics_expanded') !== 'false';
977
1026
 
978
1027
  function toggleDiagnostics() {
979
1028
  diagnosticsExpanded = !diagnosticsExpanded;
@@ -128,7 +128,7 @@
128
128
  <p class="text-sm font-medium text-gray-900 dark:text-gray-100">Parent Controller</p>
129
129
  <p class="text-xs text-gray-500 dark:text-gray-400">Dashboard inherits from this</p>
130
130
  </div>
131
- <code class="bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-2 py-1 rounded text-sm text-xs"><%= @config.dashboard_parent_controller %></code>
131
+ <code class="bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-2 py-1 rounded text-xs"><%= @config.dashboard_parent_controller %></code>
132
132
  </div>
133
133
  <div class="flex justify-between items-center">
134
134
  <div>
@@ -0,0 +1,48 @@
1
+ <%
2
+ # Breadcrumb navigation partial
3
+ # Usage:
4
+ # render "rubyllm/agents/shared/breadcrumbs", items: [
5
+ # { label: "Dashboard", path: ruby_llm_agents.root_path },
6
+ # { label: "Executions", path: ruby_llm_agents.executions_path },
7
+ # { label: "#123" } # Current page (no path)
8
+ # ]
9
+ #
10
+ # Or with simple array:
11
+ # render "rubyllm/agents/shared/breadcrumbs", items: [
12
+ # ["Dashboard", ruby_llm_agents.root_path],
13
+ # ["Executions", ruby_llm_agents.executions_path],
14
+ # ["#123"]
15
+ # ]
16
+
17
+ items = local_assigns[:items] || []
18
+ %>
19
+ <nav class="flex items-center text-sm mb-4" aria-label="Breadcrumb">
20
+ <ol class="flex items-center space-x-1">
21
+ <% items.each_with_index do |item, index| %>
22
+ <%
23
+ # Normalize item format
24
+ if item.is_a?(Array)
25
+ label = item[0]
26
+ path = item[1]
27
+ else
28
+ label = item[:label]
29
+ path = item[:path]
30
+ end
31
+ is_last = index == items.length - 1
32
+ %>
33
+ <li class="flex items-center">
34
+ <% if index > 0 %>
35
+ <svg class="w-4 h-4 text-gray-400 dark:text-gray-500 mx-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
36
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
37
+ </svg>
38
+ <% end %>
39
+
40
+ <% if path.present? && !is_last %>
41
+ <%= link_to label, path, class: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors" %>
42
+ <% else %>
43
+ <span class="text-gray-900 dark:text-gray-100 font-medium"><%= label %></span>
44
+ <% end %>
45
+ </li>
46
+ <% end %>
47
+ </ol>
48
+ </nav>
@@ -0,0 +1,27 @@
1
+ <%# Reusable navigation link for desktop and mobile %>
2
+ <%
3
+ # Determine if link is active
4
+ is_active = if path == ruby_llm_agents.root_path
5
+ current_page?(path)
6
+ elsif path == ruby_llm_agents.agents_path
7
+ request.path.start_with?(path)
8
+ else
9
+ current_page?(path)
10
+ end
11
+
12
+ # Style classes
13
+ base_classes = mobile ? "flex items-center px-3 py-2 text-base font-medium rounded-md" : "inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md"
14
+ active_classes = "bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100"
15
+ inactive_classes = "text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700"
16
+ icon_classes = mobile ? "w-5 h-5 mr-3" : "w-4 h-4 mr-1.5"
17
+
18
+ link_options = { class: "#{base_classes} #{is_active ? active_classes : inactive_classes}" }
19
+ link_options["x-on:click"] = "mobileMenuOpen = false" if mobile
20
+ %>
21
+
22
+ <%= link_to path, **link_options do %>
23
+ <svg class="<%= icon_classes %>" fill="none" stroke="currentColor" viewBox="0 0 24 24">
24
+ <%= icon.html_safe %>
25
+ </svg>
26
+ <%= label %>
27
+ <% end %>
data/config/routes.rb CHANGED
@@ -16,5 +16,7 @@ RubyLLM::Agents::Engine.routes.draw do
16
16
  end
17
17
  end
18
18
 
19
+ # Redirect old analytics route to dashboard
20
+ get "analytics", to: redirect("/")
19
21
  resource :settings, only: [:show]
20
22
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Base
6
+ # Cache management for agent responses
7
+ #
8
+ # Handles cache key generation and store access for
9
+ # caching agent execution results.
10
+ module Caching
11
+ # Returns the configured cache store
12
+ #
13
+ # @return [ActiveSupport::Cache::Store] The cache store
14
+ def cache_store
15
+ RubyLLM::Agents.configuration.cache_store
16
+ end
17
+
18
+ # Generates the full cache key for this agent invocation
19
+ #
20
+ # @return [String] Cache key in format "ruby_llm_agent/ClassName/version/hash"
21
+ def cache_key
22
+ ["ruby_llm_agent", self.class.name, self.class.version, cache_key_hash].join("/")
23
+ end
24
+
25
+ # Generates a hash of the cache key data
26
+ #
27
+ # @return [String] SHA256 hex digest of the cache key data
28
+ def cache_key_hash
29
+ Digest::SHA256.hexdigest(cache_key_data.to_json)
30
+ end
31
+
32
+ # Returns data to include in cache key generation
33
+ #
34
+ # Override to customize what parameters affect cache invalidation.
35
+ #
36
+ # @return [Hash] Data to hash for cache key
37
+ def cache_key_data
38
+ @options.except(:skip_cache, :dry_run, :with)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Base
6
+ # Cost calculation methods for token and pricing calculations
7
+ #
8
+ # Handles input/output cost calculations, model info resolution,
9
+ # and budget tracking for agent executions.
10
+ module CostCalculation
11
+ # Calculates input cost from tokens
12
+ #
13
+ # @param input_tokens [Integer, nil] Number of input tokens
14
+ # @param response_model_id [String, nil] Model that responded
15
+ # @return [Float, nil] Input cost in USD
16
+ def result_input_cost(input_tokens, response_model_id)
17
+ return nil unless input_tokens
18
+ model_info = result_model_info(response_model_id)
19
+ return nil unless model_info&.pricing
20
+ price = model_info.pricing.text_tokens&.input || 0
21
+ (input_tokens / 1_000_000.0 * price).round(6)
22
+ end
23
+
24
+ # Calculates output cost from tokens
25
+ #
26
+ # @param output_tokens [Integer, nil] Number of output tokens
27
+ # @param response_model_id [String, nil] Model that responded
28
+ # @return [Float, nil] Output cost in USD
29
+ def result_output_cost(output_tokens, response_model_id)
30
+ return nil unless output_tokens
31
+ model_info = result_model_info(response_model_id)
32
+ return nil unless model_info&.pricing
33
+ price = model_info.pricing.text_tokens&.output || 0
34
+ (output_tokens / 1_000_000.0 * price).round(6)
35
+ end
36
+
37
+ # Calculates total cost from tokens
38
+ #
39
+ # @param input_tokens [Integer, nil] Number of input tokens
40
+ # @param output_tokens [Integer, nil] Number of output tokens
41
+ # @param response_model_id [String, nil] Model that responded
42
+ # @return [Float, nil] Total cost in USD
43
+ def result_total_cost(input_tokens, output_tokens, response_model_id)
44
+ input_cost = result_input_cost(input_tokens, response_model_id)
45
+ output_cost = result_output_cost(output_tokens, response_model_id)
46
+ return nil unless input_cost || output_cost
47
+ ((input_cost || 0) + (output_cost || 0)).round(6)
48
+ end
49
+
50
+ # Resolves model info for cost calculation
51
+ #
52
+ # @param response_model_id [String, nil] Model ID from response
53
+ # @return [Object, nil] Model info or nil
54
+ def result_model_info(response_model_id)
55
+ lookup_id = response_model_id || model
56
+ return nil unless lookup_id
57
+ model_obj, _provider = RubyLLM::Models.resolve(lookup_id)
58
+ model_obj
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ # Resolves model info for cost calculation (alternate method)
64
+ #
65
+ # @param model_id [String] The model identifier
66
+ # @return [Object, nil] Model info or nil
67
+ def resolve_model_info(model_id)
68
+ RubyLLM::Models.resolve(model_id)
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ # Records cost from an attempt to the budget tracker
74
+ #
75
+ # @param attempt_tracker [AttemptTracker] The attempt tracker
76
+ # @return [void]
77
+ def record_attempt_cost(attempt_tracker)
78
+ successful = attempt_tracker.successful_attempt
79
+ return unless successful
80
+
81
+ # Calculate cost for this execution
82
+ # Note: Full cost calculation happens in instrumentation, but we
83
+ # record the spend here for budget tracking
84
+ model_info = resolve_model_info(successful[:model_id])
85
+ return unless model_info&.pricing
86
+
87
+ input_tokens = successful[:input_tokens] || 0
88
+ output_tokens = successful[:output_tokens] || 0
89
+
90
+ input_price = model_info.pricing.text_tokens&.input || 0
91
+ output_price = model_info.pricing.text_tokens&.output || 0
92
+
93
+ total_cost = (input_tokens / 1_000_000.0 * input_price) +
94
+ (output_tokens / 1_000_000.0 * output_price)
95
+
96
+ BudgetTracker.record_spend!(self.class.name, total_cost)
97
+ rescue StandardError => e
98
+ Rails.logger.warn("[RubyLLM::Agents] Failed to record budget spend: #{e.message}")
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end