ruby_llm-agents 0.1.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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +898 -0
  4. data/app/channels/ruby_llm/agents/executions_channel.rb +23 -0
  5. data/app/controllers/ruby_llm/agents/agents_controller.rb +100 -0
  6. data/app/controllers/ruby_llm/agents/application_controller.rb +20 -0
  7. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +34 -0
  8. data/app/controllers/ruby_llm/agents/executions_controller.rb +93 -0
  9. data/app/helpers/ruby_llm/agents/application_helper.rb +149 -0
  10. data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +56 -0
  11. data/app/javascript/ruby_llm/agents/controllers/index.js +12 -0
  12. data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +83 -0
  13. data/app/models/ruby_llm/agents/execution/analytics.rb +166 -0
  14. data/app/models/ruby_llm/agents/execution/metrics.rb +89 -0
  15. data/app/models/ruby_llm/agents/execution/scopes.rb +81 -0
  16. data/app/models/ruby_llm/agents/execution.rb +81 -0
  17. data/app/services/ruby_llm/agents/agent_registry.rb +112 -0
  18. data/app/views/layouts/rubyllm/agents/application.html.erb +276 -0
  19. data/app/views/rubyllm/agents/agents/index.html.erb +89 -0
  20. data/app/views/rubyllm/agents/agents/show.html.erb +562 -0
  21. data/app/views/rubyllm/agents/dashboard/_execution_item.html.erb +48 -0
  22. data/app/views/rubyllm/agents/dashboard/index.html.erb +121 -0
  23. data/app/views/rubyllm/agents/executions/_execution.html.erb +64 -0
  24. data/app/views/rubyllm/agents/executions/_filters.html.erb +172 -0
  25. data/app/views/rubyllm/agents/executions/_list.html.erb +229 -0
  26. data/app/views/rubyllm/agents/executions/index.html.erb +83 -0
  27. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +4 -0
  28. data/app/views/rubyllm/agents/executions/show.html.erb +240 -0
  29. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +193 -0
  30. data/app/views/rubyllm/agents/shared/_stat_card.html.erb +14 -0
  31. data/app/views/rubyllm/agents/shared/_status_badge.html.erb +65 -0
  32. data/app/views/rubyllm/agents/shared/_status_dot.html.erb +18 -0
  33. data/config/routes.rb +13 -0
  34. data/lib/generators/ruby_llm_agents/agent_generator.rb +79 -0
  35. data/lib/generators/ruby_llm_agents/install_generator.rb +89 -0
  36. data/lib/generators/ruby_llm_agents/templates/add_prompts_migration.rb.tt +12 -0
  37. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +46 -0
  38. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +22 -0
  39. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +36 -0
  40. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +66 -0
  41. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +59 -0
  42. data/lib/ruby_llm/agents/base.rb +271 -0
  43. data/lib/ruby_llm/agents/configuration.rb +36 -0
  44. data/lib/ruby_llm/agents/engine.rb +32 -0
  45. data/lib/ruby_llm/agents/execution_logger_job.rb +59 -0
  46. data/lib/ruby_llm/agents/inflections.rb +13 -0
  47. data/lib/ruby_llm/agents/instrumentation.rb +245 -0
  48. data/lib/ruby_llm/agents/version.rb +7 -0
  49. data/lib/ruby_llm/agents.rb +26 -0
  50. data/lib/ruby_llm-agents.rb +3 -0
  51. metadata +164 -0
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # ActionCable channel for real-time execution updates
6
+ #
7
+ # Broadcasts execution create/update events to subscribed clients.
8
+ # Used by the dashboard to show live execution status changes.
9
+ #
10
+ # Inherits from the host app's ApplicationCable::Channel (note the :: prefix)
11
+ #
12
+ class ExecutionsChannel < ::ApplicationCable::Channel
13
+ def subscribed
14
+ stream_from "ruby_llm_agents:executions"
15
+ logger.info "[RubyLLM::Agents] Client subscribed to executions channel"
16
+ end
17
+
18
+ def unsubscribed
19
+ logger.info "[RubyLLM::Agents] Client unsubscribed from executions channel"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class AgentsController < ApplicationController
6
+ def index
7
+ @agents = AgentRegistry.all_with_details
8
+ end
9
+
10
+ def show
11
+ @agent_type = params[:id]
12
+ @agent_class = AgentRegistry.find(@agent_type)
13
+ @agent_active = @agent_class.present?
14
+
15
+ # Get stats for different time periods
16
+ @stats = Execution.stats_for(@agent_type, period: :all_time)
17
+ @stats_today = Execution.stats_for(@agent_type, period: :today)
18
+
19
+ # Get available filter options for this agent
20
+ @versions = Execution.by_agent(@agent_type).distinct.pluck(:agent_version).compact.sort.reverse
21
+ @models = Execution.by_agent(@agent_type).distinct.pluck(:model_id).compact.sort
22
+ @temperatures = Execution.by_agent(@agent_type).distinct.pluck(:temperature).compact.sort
23
+
24
+ # Build filtered scope
25
+ base_scope = Execution.by_agent(@agent_type)
26
+
27
+ # Apply status filter
28
+ if params[:statuses].present?
29
+ statuses = params[:statuses].is_a?(Array) ? params[:statuses] : params[:statuses].split(",")
30
+ base_scope = base_scope.where(status: statuses) if statuses.any?(&:present?)
31
+ end
32
+
33
+ # Apply version filter
34
+ if params[:versions].present?
35
+ versions = params[:versions].is_a?(Array) ? params[:versions] : params[:versions].split(",")
36
+ base_scope = base_scope.where(agent_version: versions) if versions.any?(&:present?)
37
+ end
38
+
39
+ # Apply model filter
40
+ if params[:models].present?
41
+ models = params[:models].is_a?(Array) ? params[:models] : params[:models].split(",")
42
+ base_scope = base_scope.where(model_id: models) if models.any?(&:present?)
43
+ end
44
+
45
+ # Apply temperature filter
46
+ if params[:temperatures].present?
47
+ temps = params[:temperatures].is_a?(Array) ? params[:temperatures] : params[:temperatures].split(",")
48
+ base_scope = base_scope.where(temperature: temps) if temps.any?(&:present?)
49
+ end
50
+
51
+ # Apply time range filter
52
+ base_scope = base_scope.where("created_at >= ?", params[:days].to_i.days.ago) if params[:days].present?
53
+
54
+ # Paginate
55
+ page = (params[:page] || 1).to_i
56
+ per_page = 25
57
+ offset = (page - 1) * per_page
58
+
59
+ filtered_scope = base_scope.order(created_at: :desc)
60
+ total_count = filtered_scope.count
61
+ @executions = filtered_scope.limit(per_page).offset(offset)
62
+
63
+ @pagination = {
64
+ current_page: page,
65
+ per_page: per_page,
66
+ total_count: total_count,
67
+ total_pages: (total_count.to_f / per_page).ceil
68
+ }
69
+
70
+ # Filter stats for summary display
71
+ @filter_stats = {
72
+ total_count: total_count,
73
+ total_cost: base_scope.sum(:total_cost),
74
+ total_tokens: base_scope.sum(:total_tokens)
75
+ }
76
+
77
+ # Get trend data for charts (30 days)
78
+ @trend_data = Execution.trend_analysis(agent_type: @agent_type, days: 30)
79
+
80
+ # Get status distribution for pie chart
81
+ @status_distribution = Execution.by_agent(@agent_type)
82
+ .group(:status)
83
+ .count
84
+
85
+ # Agent configuration (if class exists)
86
+ if @agent_class
87
+ @config = {
88
+ model: @agent_class.model,
89
+ temperature: @agent_class.temperature,
90
+ version: @agent_class.version,
91
+ timeout: @agent_class.timeout,
92
+ cache_enabled: @agent_class.cache_enabled?,
93
+ cache_ttl: @agent_class.cache_ttl,
94
+ params: @agent_class.params
95
+ }
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class ApplicationController < ActionController::Base
6
+ layout "rubyllm/agents/application"
7
+
8
+ before_action :authenticate_dashboard!
9
+
10
+ private
11
+
12
+ def authenticate_dashboard!
13
+ auth_proc = RubyLLM::Agents.configuration.dashboard_auth
14
+ return if auth_proc.call(self)
15
+
16
+ render plain: "Unauthorized", status: :unauthorized
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class DashboardController < ApplicationController
6
+ def index
7
+ @stats = daily_stats
8
+ @recent_executions = Execution.recent(10)
9
+ @hourly_activity = Execution.hourly_activity_chart
10
+ end
11
+
12
+ private
13
+
14
+ def daily_stats
15
+ scope = Execution.today
16
+ {
17
+ total_executions: scope.count,
18
+ successful: scope.successful.count,
19
+ failed: scope.failed.count,
20
+ total_cost: scope.total_cost_sum || 0,
21
+ total_tokens: scope.total_tokens_sum || 0,
22
+ avg_duration_ms: scope.avg_duration&.round || 0,
23
+ success_rate: calculate_success_rate(scope)
24
+ }
25
+ end
26
+
27
+ def calculate_success_rate(scope)
28
+ total = scope.count
29
+ return 0.0 if total.zero?
30
+ (scope.successful.count.to_f / total * 100).round(1)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class ExecutionsController < ApplicationController
6
+ def index
7
+ @agent_types = Execution.distinct.pluck(:agent_type)
8
+ @statuses = Execution.statuses.keys
9
+ load_paginated_executions
10
+ load_filter_stats
11
+
12
+ respond_to do |format|
13
+ format.html
14
+ format.turbo_stream
15
+ end
16
+ end
17
+
18
+ def show
19
+ @execution = Execution.find(params[:id])
20
+ end
21
+
22
+ def search
23
+ @agent_types = Execution.distinct.pluck(:agent_type)
24
+ @statuses = Execution.statuses.keys
25
+ load_paginated_executions
26
+ load_filter_stats
27
+
28
+ respond_to do |format|
29
+ format.html { render :index }
30
+ format.turbo_stream do
31
+ render turbo_stream: turbo_stream.replace(
32
+ "executions_list",
33
+ partial: "ruby_llm/agents/executions/list",
34
+ locals: { executions: @executions, pagination: @pagination, filter_stats: @filter_stats }
35
+ )
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def load_paginated_executions
43
+ page = (params[:page] || 1).to_i
44
+ per_page = 25
45
+ offset = (page - 1) * per_page
46
+
47
+ base_scope = filtered_executions.order(created_at: :desc)
48
+ total_count = base_scope.count
49
+ @executions = base_scope.limit(per_page).offset(offset)
50
+
51
+ @pagination = {
52
+ current_page: page,
53
+ per_page: per_page,
54
+ total_count: total_count,
55
+ total_pages: (total_count.to_f / per_page).ceil
56
+ }
57
+ end
58
+
59
+ def load_filter_stats
60
+ scope = filtered_executions
61
+ @filter_stats = {
62
+ total_count: scope.count,
63
+ total_cost: scope.sum(:total_cost) || 0,
64
+ total_tokens: scope.sum(:total_tokens) || 0
65
+ }
66
+ end
67
+
68
+ def filtered_executions
69
+ scope = Execution.all
70
+
71
+ # Support multiple agent types (comma-separated or array)
72
+ if params[:agent_types].present?
73
+ agent_types = params[:agent_types].is_a?(Array) ? params[:agent_types] : params[:agent_types].split(",")
74
+ scope = scope.where(agent_type: agent_types) if agent_types.any?(&:present?)
75
+ elsif params[:agent_type].present?
76
+ scope = scope.by_agent(params[:agent_type])
77
+ end
78
+
79
+ # Support multiple statuses (comma-separated or array)
80
+ if params[:statuses].present?
81
+ statuses = params[:statuses].is_a?(Array) ? params[:statuses] : params[:statuses].split(",")
82
+ scope = scope.where(status: statuses) if statuses.any?(&:present?)
83
+ elsif params[:status].present?
84
+ scope = scope.where(status: params[:status])
85
+ end
86
+
87
+ scope = scope.where("created_at >= ?", params[:days].to_i.days.ago) if params[:days].present?
88
+
89
+ scope
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module ApplicationHelper
6
+ include Chartkick::Helper if defined?(Chartkick)
7
+
8
+ def ruby_llm_agents
9
+ RubyLLM::Agents::Engine.routes.url_helpers
10
+ end
11
+
12
+ def number_to_human_short(number, prefix: nil, precision: 1)
13
+ return "#{prefix}0" if number.nil? || number.zero?
14
+
15
+ abs_number = number.to_f.abs
16
+ formatted = if abs_number >= 1_000_000_000
17
+ "#{(number / 1_000_000_000.0).round(precision)}B"
18
+ elsif abs_number >= 1_000_000
19
+ "#{(number / 1_000_000.0).round(precision)}M"
20
+ elsif abs_number >= 1_000
21
+ "#{(number / 1_000.0).round(precision)}K"
22
+ elsif abs_number < 1 && abs_number > 0
23
+ number.round(precision + 3).to_s
24
+ else
25
+ number.round(precision).to_s
26
+ end
27
+
28
+ "#{prefix}#{formatted}"
29
+ end
30
+
31
+ def highlight_json(obj)
32
+ return "" if obj.nil?
33
+
34
+ json_string = JSON.pretty_generate(obj)
35
+ highlight_json_string(json_string)
36
+ end
37
+
38
+ def highlight_json_string(json_string)
39
+ return "" if json_string.blank?
40
+
41
+ tokens = []
42
+ i = 0
43
+ chars = json_string.chars
44
+
45
+ while i < chars.length
46
+ char = chars[i]
47
+
48
+ case char
49
+ when '"'
50
+ # Parse string
51
+ str_start = i
52
+ i += 1
53
+ while i < chars.length
54
+ if chars[i] == '\\'
55
+ i += 2
56
+ elsif chars[i] == '"'
57
+ i += 1
58
+ break
59
+ else
60
+ i += 1
61
+ end
62
+ end
63
+ tokens << { type: :string, value: chars[str_start...i].join }
64
+ when /[0-9\-]/
65
+ # Parse number
66
+ num_start = i
67
+ i += 1
68
+ while i < chars.length && chars[i] =~ /[0-9.eE+\-]/
69
+ i += 1
70
+ end
71
+ tokens << { type: :number, value: chars[num_start...i].join }
72
+ when 't'
73
+ # true
74
+ if chars[i, 4].join == 'true'
75
+ tokens << { type: :boolean, value: 'true' }
76
+ i += 4
77
+ else
78
+ tokens << { type: :text, value: char }
79
+ i += 1
80
+ end
81
+ when 'f'
82
+ # false
83
+ if chars[i, 5].join == 'false'
84
+ tokens << { type: :boolean, value: 'false' }
85
+ i += 5
86
+ else
87
+ tokens << { type: :text, value: char }
88
+ i += 1
89
+ end
90
+ when 'n'
91
+ # null
92
+ if chars[i, 4].join == 'null'
93
+ tokens << { type: :null, value: 'null' }
94
+ i += 4
95
+ else
96
+ tokens << { type: :text, value: char }
97
+ i += 1
98
+ end
99
+ when ':', ',', '{', '}', '[', ']', ' ', "\n", "\t"
100
+ tokens << { type: :punct, value: char }
101
+ i += 1
102
+ else
103
+ tokens << { type: :text, value: char }
104
+ i += 1
105
+ end
106
+ end
107
+
108
+ # Build highlighted HTML - detect if string is a key (followed by :)
109
+ result = []
110
+ tokens.each_with_index do |token, idx|
111
+ case token[:type]
112
+ when :string
113
+ # Check if this is a key (next non-whitespace token is :)
114
+ is_key = false
115
+ (idx + 1...tokens.length).each do |j|
116
+ if tokens[j][:type] == :punct
117
+ if tokens[j][:value] == ':'
118
+ is_key = true
119
+ break
120
+ elsif tokens[j][:value] !~ /\s/
121
+ break
122
+ end
123
+ else
124
+ break
125
+ end
126
+ end
127
+
128
+ escaped_value = ERB::Util.html_escape(token[:value])
129
+ if is_key
130
+ result << %(<span class="text-purple-600">#{escaped_value}</span>)
131
+ else
132
+ result << %(<span class="text-green-600">#{escaped_value}</span>)
133
+ end
134
+ when :number
135
+ result << %(<span class="text-blue-600">#{token[:value]}</span>)
136
+ when :boolean
137
+ result << %(<span class="text-amber-600">#{token[:value]}</span>)
138
+ when :null
139
+ result << %(<span class="text-gray-400">#{token[:value]}</span>)
140
+ else
141
+ result << ERB::Util.html_escape(token[:value])
142
+ end
143
+ end
144
+
145
+ result.join.html_safe
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,56 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Filter controller for live filtering of executions
4
+ //
5
+ // Usage:
6
+ // <form data-controller="filter">
7
+ // <select data-action="change->filter#submit">...</select>
8
+ // </form>
9
+ //
10
+ export default class extends Controller {
11
+ static targets = ["form"]
12
+ static values = {
13
+ debounce: { type: Number, default: 300 }
14
+ }
15
+
16
+ connect() {
17
+ this.timeout = null
18
+ }
19
+
20
+ disconnect() {
21
+ if (this.timeout) {
22
+ clearTimeout(this.timeout)
23
+ }
24
+ }
25
+
26
+ // Submit the form with debouncing
27
+ submit(event) {
28
+ if (this.timeout) {
29
+ clearTimeout(this.timeout)
30
+ }
31
+
32
+ this.timeout = setTimeout(() => {
33
+ this.element.requestSubmit()
34
+ }, this.debounceValue)
35
+ }
36
+
37
+ // Submit immediately without debounce
38
+ submitNow(event) {
39
+ this.element.requestSubmit()
40
+ }
41
+
42
+ // Update URL with current filter state
43
+ updateUrl() {
44
+ const formData = new FormData(this.element)
45
+ const params = new URLSearchParams()
46
+
47
+ for (const [key, value] of formData) {
48
+ if (value) {
49
+ params.set(key, value)
50
+ }
51
+ }
52
+
53
+ const newUrl = `${window.location.pathname}?${params.toString()}`
54
+ window.history.pushState({}, "", newUrl)
55
+ }
56
+ }
@@ -0,0 +1,12 @@
1
+ // Entry point for RubyLLM::Agents Stimulus controllers
2
+ // Import and register controllers with your Stimulus application
3
+
4
+ import FilterController from "./filter_controller"
5
+ import RefreshController from "./refresh_controller"
6
+
7
+ export { FilterController, RefreshController }
8
+
9
+ export function registerControllers(application) {
10
+ application.register("filter", FilterController)
11
+ application.register("refresh", RefreshController)
12
+ }
@@ -0,0 +1,83 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Auto-refresh controller for dashboard with toggle support
4
+ //
5
+ // Usage:
6
+ // <div data-controller="refresh"
7
+ // data-refresh-interval-value="30000"
8
+ // data-refresh-enabled-value="false">
9
+ // <button data-action="refresh#toggle" data-refresh-target="button">
10
+ // Live Poll: Off
11
+ // </button>
12
+ // </div>
13
+ //
14
+ export default class extends Controller {
15
+ static values = {
16
+ interval: { type: Number, default: 30000 },
17
+ enabled: { type: Boolean, default: false }
18
+ }
19
+
20
+ static targets = ["button", "indicator"]
21
+
22
+ connect() {
23
+ this.updateUI()
24
+ if (this.enabledValue) {
25
+ this.startRefresh()
26
+ }
27
+ }
28
+
29
+ disconnect() {
30
+ this.stopRefresh()
31
+ }
32
+
33
+ toggle() {
34
+ this.enabledValue = !this.enabledValue
35
+
36
+ if (this.enabledValue) {
37
+ this.startRefresh()
38
+ this.refresh() // Immediate refresh when enabled
39
+ } else {
40
+ this.stopRefresh()
41
+ }
42
+
43
+ this.updateUI()
44
+ }
45
+
46
+ startRefresh() {
47
+ if (this.intervalValue > 0 && !this.timer) {
48
+ this.timer = setInterval(() => this.refresh(), this.intervalValue)
49
+ }
50
+ }
51
+
52
+ stopRefresh() {
53
+ if (this.timer) {
54
+ clearInterval(this.timer)
55
+ this.timer = null
56
+ }
57
+ }
58
+
59
+ refresh() {
60
+ const frame = this.element.closest("turbo-frame") ||
61
+ this.element.querySelector("turbo-frame")
62
+
63
+ if (frame && typeof frame.reload === "function") {
64
+ frame.reload()
65
+ }
66
+ }
67
+
68
+ updateUI() {
69
+ if (this.hasButtonTarget) {
70
+ if (this.enabledValue) {
71
+ this.buttonTarget.classList.remove("bg-gray-100", "text-gray-600")
72
+ this.buttonTarget.classList.add("bg-green-100", "text-green-700")
73
+ } else {
74
+ this.buttonTarget.classList.remove("bg-green-100", "text-green-700")
75
+ this.buttonTarget.classList.add("bg-gray-100", "text-gray-600")
76
+ }
77
+ }
78
+
79
+ if (this.hasIndicatorTarget) {
80
+ this.indicatorTarget.textContent = this.enabledValue ? "On" : "Off"
81
+ }
82
+ }
83
+ }