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.
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 +58 -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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03e22b362e20b0322d49726f9bb22f202d4fa642691f3a3b885d5cc4e7c661cf
4
- data.tar.gz: 327a34b0ef344b4a65edf23fda0360b1e2bcc5f33e43881521418686f2113565
3
+ metadata.gz: d0cf7b20ca960eab7d26da1c568c5aa5078e12e887a4d93c9b472d6897cf1e5e
4
+ data.tar.gz: 59372d2c80bd8fdb23d49ffdd672911948bf1b78da8243aaac7df926a378bd59
5
5
  SHA512:
6
- metadata.gz: 83bdf11edc73064e92f98b220849e5d29ad62aa79ee853880eda80763c70e4a7aec13129f299df70db6dc3a9d08c102a12ca857ca6557b9b6a56db81430ea38a
7
- data.tar.gz: 8c8f29bb00b446e44d2b15578b823812f72e533976244407549bcf4414db51a8e33676fdbb36ebab41a7f22d2d85a1a63de1d2c0c7e40d5b58ebfad128a9a634
6
+ metadata.gz: 1902ad245d20d405fe69633e122a83302bbd5b95e5b65fe0d24b1da14b0effaf27e9308e4a5a42bd9072545b2d128ed173214812667727fb2ea0de43948f1ee1
7
+ data.tar.gz: d3937f03bc62481c6257c55c9a7f1a8c6eccc9fc08a759c8f40334b21000a12cc67e5277ca0d018b551c3abb6b077d4ffc1caa8bfaa3f2d8e784aac98c1e9bf8
data/README.md CHANGED
@@ -12,6 +12,10 @@ A powerful Rails engine for building, managing, and monitoring LLM-powered agent
12
12
  - **🛠️ Generators** - Quickly scaffold new agents with customizable templates
13
13
  - **🔍 Anomaly Detection** - Automatic warnings for unusual cost or duration patterns
14
14
  - **🎯 Type Safety** - Structured output with RubyLLM::Schema integration
15
+ - **🔄 Reliability** - Automatic retries, model fallbacks, and circuit breakers for resilient agents
16
+ - **💵 Budget Controls** - Daily/monthly spending limits with hard and soft enforcement
17
+ - **🔔 Alerts** - Slack, webhook, and custom notifications for budget and circuit breaker events
18
+ - **🔒 PII Redaction** - Automatic sanitization of sensitive data in execution logs
15
19
 
16
20
  ## Requirements
17
21
 
@@ -394,6 +398,269 @@ class RecommendationAgent < ApplicationAgent
394
398
  end
395
399
  ```
396
400
 
401
+ ## Reliability Features
402
+
403
+ RubyLLM::Agents provides built-in reliability features to make your agents resilient against API failures, rate limits, and transient errors.
404
+
405
+ ### Automatic Retries
406
+
407
+ Configure retry behavior for transient failures:
408
+
409
+ ```ruby
410
+ class ReliableAgent < ApplicationAgent
411
+ model "gpt-4o"
412
+
413
+ # Retry up to 3 times with exponential backoff
414
+ retries max: 3, backoff: :exponential, base: 0.5, max_delay: 10.0
415
+
416
+ # Only retry on specific errors (defaults include timeout, network errors)
417
+ retries max: 3, on: [Timeout::Error, Net::ReadTimeout, Faraday::TimeoutError]
418
+
419
+ param :query, required: true
420
+
421
+ def user_prompt
422
+ query
423
+ end
424
+ end
425
+ ```
426
+
427
+ Backoff strategies:
428
+ - `:exponential` - Delay doubles each retry (0.5s, 1s, 2s, 4s...)
429
+ - `:constant` - Same delay each retry
430
+ - Jitter is automatically added to prevent thundering herd
431
+
432
+ ### Model Fallbacks
433
+
434
+ Automatically try alternative models if the primary fails:
435
+
436
+ ```ruby
437
+ class FallbackAgent < ApplicationAgent
438
+ model "gpt-4o"
439
+
440
+ # Try these models in order if primary fails
441
+ fallback_models "gpt-4o-mini", "claude-3-5-sonnet", "gemini-2.0-flash"
442
+
443
+ # Combine with retries
444
+ retries max: 2
445
+ fallback_models "gpt-4o-mini", "claude-3-sonnet"
446
+
447
+ param :query, required: true
448
+
449
+ def user_prompt
450
+ query
451
+ end
452
+ end
453
+ ```
454
+
455
+ The agent will try `gpt-4o` (with 2 retries), then `gpt-4o-mini` (with 2 retries), and so on.
456
+
457
+ ### Circuit Breaker
458
+
459
+ Prevent cascading failures by temporarily blocking requests to failing models:
460
+
461
+ ```ruby
462
+ class ProtectedAgent < ApplicationAgent
463
+ model "gpt-4o"
464
+ fallback_models "claude-3-sonnet"
465
+
466
+ # Open circuit after 10 errors within 60 seconds
467
+ # Keep circuit open for 5 minutes before retrying
468
+ circuit_breaker errors: 10, within: 60, cooldown: 300
469
+
470
+ param :query, required: true
471
+
472
+ def user_prompt
473
+ query
474
+ end
475
+ end
476
+ ```
477
+
478
+ Circuit breaker states:
479
+ - **Closed** - Normal operation, requests pass through
480
+ - **Open** - Model is blocked, requests skip to fallback or fail fast
481
+ - **Half-Open** - After cooldown, one request is allowed to test recovery
482
+
483
+ ### Total Timeout
484
+
485
+ Set a maximum time for the entire operation including all retries:
486
+
487
+ ```ruby
488
+ class TimeBoundAgent < ApplicationAgent
489
+ model "gpt-4o"
490
+ retries max: 5
491
+ fallback_models "gpt-4o-mini"
492
+
493
+ # Abort everything after 30 seconds total
494
+ total_timeout 30
495
+
496
+ param :query, required: true
497
+
498
+ def user_prompt
499
+ query
500
+ end
501
+ end
502
+ ```
503
+
504
+ ### Viewing Attempt Details
505
+
506
+ When reliability features are enabled, the dashboard shows all attempts:
507
+
508
+ ```ruby
509
+ execution = RubyLLM::Agents::Execution.last
510
+
511
+ # Check if retries/fallbacks were used
512
+ execution.has_retries? # => true
513
+ execution.used_fallback? # => true
514
+ execution.attempts_count # => 3
515
+
516
+ # Get attempt details
517
+ execution.attempts.each do |attempt|
518
+ puts "Model: #{attempt['model_id']}"
519
+ puts "Duration: #{attempt['duration_ms']}ms"
520
+ puts "Error: #{attempt['error_class']}" if attempt['error_class']
521
+ puts "Short-circuited: #{attempt['short_circuited']}"
522
+ end
523
+
524
+ # Find the successful attempt
525
+ execution.successful_attempt # => Hash with attempt data
526
+ execution.chosen_model_id # => "claude-3-sonnet" (the model that succeeded)
527
+ ```
528
+
529
+ ## Governance & Cost Controls
530
+
531
+ ### Budget Limits
532
+
533
+ Set spending limits at global and per-agent levels:
534
+
535
+ ```ruby
536
+ # config/initializers/ruby_llm_agents.rb
537
+ RubyLLM::Agents.configure do |config|
538
+ config.budgets = {
539
+ # Global limits apply to all agents combined
540
+ global_daily: 100.0, # $100/day across all agents
541
+ global_monthly: 2000.0, # $2000/month across all agents
542
+
543
+ # Per-agent limits
544
+ per_agent_daily: {
545
+ "ExpensiveAgent" => 50.0, # $50/day for this agent
546
+ "CheapAgent" => 5.0 # $5/day for this agent
547
+ },
548
+ per_agent_monthly: {
549
+ "ExpensiveAgent" => 500.0
550
+ },
551
+
552
+ # Enforcement mode
553
+ # :hard - Block requests when budget exceeded
554
+ # :soft - Allow requests but log warnings
555
+ enforcement: :hard
556
+ }
557
+ end
558
+ ```
559
+
560
+ Querying budget status:
561
+
562
+ ```ruby
563
+ # Get current budget status
564
+ status = RubyLLM::Agents::BudgetTracker.status(agent_type: "MyAgent")
565
+ # => {
566
+ # global_daily: { limit: 100.0, current: 45.50, remaining: 54.50, percentage_used: 45.5 },
567
+ # global_monthly: { limit: 2000.0, current: 890.0, remaining: 1110.0, percentage_used: 44.5 }
568
+ # }
569
+
570
+ # Check remaining budget
571
+ RubyLLM::Agents::BudgetTracker.remaining_budget(:global, :daily)
572
+ # => 54.50
573
+ ```
574
+
575
+ ### Alerts
576
+
577
+ Get notified when important events occur:
578
+
579
+ ```ruby
580
+ # config/initializers/ruby_llm_agents.rb
581
+ RubyLLM::Agents.configure do |config|
582
+ config.alerts = {
583
+ # Events to alert on
584
+ on_events: [
585
+ :budget_soft_cap, # Budget threshold reached (configurable %)
586
+ :budget_hard_cap, # Budget exceeded (with hard enforcement)
587
+ :breaker_open # Circuit breaker opened
588
+ ],
589
+
590
+ # Slack webhook
591
+ slack_webhook_url: ENV['SLACK_WEBHOOK_URL'],
592
+
593
+ # Generic webhook (receives JSON payload)
594
+ webhook_url: "https://your-app.com/webhooks/llm-alerts",
595
+
596
+ # Custom handler
597
+ custom: ->(event, payload) {
598
+ # event: :budget_hard_cap
599
+ # payload: { scope: :global_daily, limit: 100.0, current: 105.0 }
600
+
601
+ MyNotificationService.notify(
602
+ title: "LLM Budget Alert",
603
+ message: "#{event}: #{payload}"
604
+ )
605
+ }
606
+ }
607
+ end
608
+ ```
609
+
610
+ Alert payload examples:
611
+
612
+ ```ruby
613
+ # Budget alert
614
+ {
615
+ event: :budget_hard_cap,
616
+ scope: :global_daily,
617
+ limit: 100.0,
618
+ current: 105.50,
619
+ agent_type: "ExpensiveAgent"
620
+ }
621
+
622
+ # Circuit breaker alert
623
+ {
624
+ event: :breaker_open,
625
+ agent_type: "MyAgent",
626
+ model_id: "gpt-4o",
627
+ failure_count: 10,
628
+ window_seconds: 60
629
+ }
630
+ ```
631
+
632
+ ### PII Redaction
633
+
634
+ Automatically redact sensitive data from execution logs:
635
+
636
+ ```ruby
637
+ # config/initializers/ruby_llm_agents.rb
638
+ RubyLLM::Agents.configure do |config|
639
+ config.redaction = {
640
+ # Fields to redact (applied to parameters)
641
+ # Default: password, token, api_key, secret, credential, auth, key, access_token
642
+ fields: %w[ssn credit_card phone_number],
643
+
644
+ # Regex patterns to redact from prompts/responses
645
+ patterns: [
646
+ /\b\d{3}-\d{2}-\d{4}\b/, # SSN
647
+ /\b\d{16}\b/, # Credit card
648
+ /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i # Email
649
+ ],
650
+
651
+ # Replacement text
652
+ placeholder: "[REDACTED]",
653
+
654
+ # Truncate long values
655
+ max_value_length: 1000
656
+ }
657
+
658
+ # Control what gets persisted
659
+ config.persist_prompts = true # Store system/user prompts
660
+ config.persist_responses = true # Store LLM responses
661
+ end
662
+ ```
663
+
397
664
  ## Configuration
398
665
 
399
666
  Edit `config/initializers/ruby_llm_agents.rb`:
@@ -689,8 +956,14 @@ rails generate ruby_llm_agents:install
689
956
  ```bash
690
957
  # Upgrade to latest schema (when gem is updated)
691
958
  rails generate ruby_llm_agents:upgrade
959
+ rails db:migrate
692
960
  ```
693
961
 
962
+ This creates migrations for new features like:
963
+ - `system_prompt` and `user_prompt` columns for prompt persistence
964
+ - `attempts` JSONB column for reliability tracking
965
+ - `chosen_model_id` for fallback model tracking
966
+
694
967
  ## Background Jobs
695
968
 
696
969
  For production environments, enable async logging:
@@ -7,14 +7,37 @@ module RubyLLM
7
7
  # Broadcasts execution create/update events to subscribed clients.
8
8
  # Used by the dashboard to show live execution status changes.
9
9
  #
10
- # Inherits from the host app's ApplicationCable::Channel (note the :: prefix)
10
+ # Inherits from the host app's ApplicationCable::Channel (note the :: prefix
11
+ # to reference the root namespace, not the engine's namespace).
11
12
  #
13
+ # @example JavaScript subscription
14
+ # import { createConsumer } from "@rails/actioncable"
15
+ # const consumer = createConsumer()
16
+ # consumer.subscriptions.create("RubyLLM::Agents::ExecutionsChannel", {
17
+ # received(data) {
18
+ # console.log("Execution update:", data)
19
+ # }
20
+ # })
21
+ #
22
+ # @see Execution#broadcast_execution Broadcast trigger
23
+ # @api private
12
24
  class ExecutionsChannel < ::ApplicationCable::Channel
25
+ # Subscribes the client to the executions broadcast stream
26
+ #
27
+ # Called automatically when a client connects to this channel.
28
+ # Streams from the "ruby_llm_agents:executions" channel name.
29
+ #
30
+ # @return [void]
13
31
  def subscribed
14
32
  stream_from "ruby_llm_agents:executions"
15
33
  logger.info "[RubyLLM::Agents] Client subscribed to executions channel"
16
34
  end
17
35
 
36
+ # Cleans up when a client disconnects
37
+ #
38
+ # Called automatically when the WebSocket connection is closed.
39
+ #
40
+ # @return [void]
18
41
  def unsubscribed
19
42
  logger.info "[RubyLLM::Agents] Client unsubscribed from executions channel"
20
43
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Controller concern for parsing and applying filters
6
+ #
7
+ # Provides methods for parsing filter parameters from requests
8
+ # and applying them to ActiveRecord scopes.
9
+ #
10
+ # @example Including in a controller
11
+ # class ExecutionsController < ApplicationController
12
+ # include Filterable
13
+ #
14
+ # def index
15
+ # statuses = parse_array_param(:statuses)
16
+ # @scope = apply_status_filter(Execution.all, statuses)
17
+ # end
18
+ # end
19
+ #
20
+ # @api private
21
+ module Filterable
22
+ extend ActiveSupport::Concern
23
+
24
+ # Valid status values for filtering
25
+ VALID_STATUSES = %w[running success error timeout].freeze
26
+
27
+ private
28
+
29
+ # Parses an array parameter from the request
30
+ #
31
+ # Handles both array format (?key[]=a&key[]=b) and
32
+ # comma-separated format (?key=a,b).
33
+ #
34
+ # @param key [Symbol] The parameter key
35
+ # @return [Array<String>] Parsed values (empty if blank)
36
+ def parse_array_param(key)
37
+ value = params[key]
38
+ return [] if value.blank?
39
+
40
+ (value.is_a?(Array) ? value : value.to_s.split(",")).select(&:present?)
41
+ end
42
+
43
+ # Parses the days parameter for time filtering
44
+ #
45
+ # @return [Integer, nil] Number of days or nil if invalid/missing
46
+ def parse_days_param
47
+ return nil unless params[:days].present?
48
+
49
+ days = params[:days].to_i
50
+ days.positive? ? days : nil
51
+ end
52
+
53
+ # Filters status values to only valid ones
54
+ #
55
+ # @param statuses [Array<String>] Status values to validate
56
+ # @return [Array<String>] Valid status values only
57
+ def validate_statuses(statuses)
58
+ statuses.select { |s| VALID_STATUSES.include?(s) }
59
+ end
60
+
61
+ # Applies status filter to a scope
62
+ #
63
+ # @param scope [ActiveRecord::Relation] The base scope
64
+ # @param statuses [Array<String>] Status values to filter by
65
+ # @return [ActiveRecord::Relation] Filtered scope
66
+ def apply_status_filter(scope, statuses)
67
+ valid_statuses = validate_statuses(statuses)
68
+ valid_statuses.any? ? scope.where(status: valid_statuses) : scope
69
+ end
70
+
71
+ # Applies time filter to a scope
72
+ #
73
+ # @param scope [ActiveRecord::Relation] The base scope
74
+ # @param days [Integer, nil] Number of days to filter by
75
+ # @return [ActiveRecord::Relation] Filtered scope
76
+ def apply_time_filter(scope, days)
77
+ days.present? && days.positive? ? scope.where("created_at >= ?", days.days.ago) : scope
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Controller concern for pagination
6
+ #
7
+ # Provides simple offset-based pagination with consistent return format.
8
+ #
9
+ # @example Using in a controller
10
+ # result = paginate(Execution.all)
11
+ # @executions = result[:records]
12
+ # @pagination = result[:pagination]
13
+ #
14
+ # @api private
15
+ module Paginatable
16
+ extend ActiveSupport::Concern
17
+
18
+ private
19
+
20
+ # Paginates a scope with optional ordering
21
+ #
22
+ # @param scope [ActiveRecord::Relation] The scope to paginate
23
+ # @param ordered [Boolean] Whether to apply default descending order (default: true)
24
+ # @return [Hash] Contains :records and :pagination keys
25
+ # @option return [ActiveRecord::Relation] :records Paginated records
26
+ # @option return [Hash] :pagination Pagination metadata
27
+ # - :current_page [Integer] Current page number
28
+ # - :per_page [Integer] Records per page
29
+ # - :total_count [Integer] Total record count
30
+ # - :total_pages [Integer] Total page count
31
+ def paginate(scope, ordered: true)
32
+ page = [(params[:page] || 1).to_i, 1].max
33
+ per_page = RubyLLM::Agents.configuration.per_page
34
+ offset = (page - 1) * per_page
35
+
36
+ scope = scope.order(created_at: :desc) if ordered
37
+ total_count = scope.count
38
+
39
+ {
40
+ records: scope.offset(offset).limit(per_page),
41
+ pagination: {
42
+ current_page: page,
43
+ per_page: per_page,
44
+ total_count: total_count,
45
+ total_pages: (total_count.to_f / per_page).ceil
46
+ }
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end