diff --git a/CHANGELOG.md b/CHANGELOG.md index e065079..d1e779c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`before_call` and `after_call` callbacks for conversation agents** - Agent-level hooks that run before and after LLM calls. Use method names or blocks. Callbacks can mutate context, raise to block execution, or inspect responses. Follows the same pattern as image pipeline's `before_pipeline`/`after_pipeline` hooks. + +### Removed + +- **BREAKING: Removed `version` DSL method** - The `version` DSL method has been removed from all agent types. This method was originally intended for cache invalidation but added complexity without significant benefit. Cache keys are now content-based, automatically generated from a hash of your prompts and parameters. This means caches invalidate automatically when you change your prompts—no manual version bumping required. If you need traceability, use `execution_metadata` instead (see migration guide below). The `agent_version` column is no longer written to; existing data will remain. The `by_version` scope and version filtering in the dashboard have also been removed. + +- **BREAKING: Removed ApiConfiguration table and model** - The `ruby_llm_agents_api_configurations` table has been removed entirely. API keys should now be configured via environment variables and the `ruby_llm` gem configuration, following 12-factor app principles. Per-tenant API keys can still be provided via the `llm_tenant` DSL's `api_keys:` option on your model. + +- **BREAKING: Removed built-in moderation system** - The `moderation` DSL, `Moderator` class, `ModerationResult`, and `ModerationError` have been removed. Use the new `before_call` hook to implement custom moderation logic with your preferred moderation service. + +- **BREAKING: Removed built-in PII redaction** - The `Redactor` utility and redaction configuration options have been removed. Use the new `before_call` hook to implement custom redaction logic. + +- **BREAKING: Removed image content policy** - The `content_policy` DSL for image generators, editors, and transformers has been removed. Implement custom content filtering in your application layer if needed. + +### Migration Guide + +**If you were using the `version` DSL:** + +1. Remove `version "X.Y"` calls from your agent classes—they'll now raise an error +2. Cache invalidation is now automatic (content-based). When you change prompts, the cache key changes automatically +3. If you need traceability (e.g., to track which "version" of an agent produced a result), use `execution_metadata`: + ```ruby + class ApplicationAgent < RubyLLM::Agents::BaseAgent + def execution_metadata + { + git_sha: ENV['GIT_SHA'] || `git rev-parse --short HEAD 2>/dev/null`.strip.presence, + deploy_version: ENV['DEPLOY_VERSION'] + }.compact + end + end + ``` +4. The `agent_version` column can optionally be removed (safe to leave in place): + ```ruby + class RemoveAgentVersionFromExecutions < ActiveRecord::Migration[7.1] + def change + safety_assured do + remove_index :ruby_llm_agents_executions, [:agent_type, :agent_version], if_exists: true + remove_column :ruby_llm_agents_executions, :agent_version, :string + end + end + end + ``` + +**If you were using the `ApiConfiguration` model:** + +1. Export any API keys stored in the database +2. Set them as environment variables instead: + ```bash + export OPENAI_API_KEY="sk-..." + export ANTHROPIC_API_KEY="sk-ant-..." + ``` +3. Configure in your initializer: + ```ruby + # config/initializers/ruby_llm.rb + RubyLLM.configure do |config| + config.openai_api_key = ENV["OPENAI_API_KEY"] + config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] + end + ``` +4. Run the migration to drop the table: + ```ruby + class RemoveApiConfigurations < ActiveRecord::Migration[7.1] + def up + drop_table :ruby_llm_agents_api_configurations, if_exists: true + end + end + ``` + +For per-tenant API keys, use the `llm_tenant` DSL: +```ruby +class Organization < ApplicationRecord + include RubyLLM::Agents::LLMTenant + + encrypts :openai_api_key, :anthropic_api_key + + llm_tenant( + id: :slug, + api_keys: { + openai: :openai_api_key, + anthropic: :anthropic_api_key + } + ) +end +``` + ## [1.3.4] - 2026-01-29 ### Improved diff --git a/LLMS.txt b/LLMS.txt index ed5d12a..1d84fa3 100644 --- a/LLMS.txt +++ b/LLMS.txt @@ -95,8 +95,7 @@ ruby_llm-agents brings structure, observability, and control to LLM-based Rails - [Multi-Tenancy Guide](wiki/Multi-Tenancy.md): Setting up multi-tenant support - [LLM Tenant](lib/ruby_llm/agents/llm_tenant.rb): Tenant concern for models -- [API Configuration](app/models/ruby_llm/agents/api_configuration.rb): Per-tenant API keys -- [Tenant Budget](app/models/ruby_llm/agents/tenant_budget.rb): Per-tenant spending limits +- [Tenant](app/models/ruby_llm/agents/tenant.rb): Per-tenant budgets and tracking ## Caching diff --git a/README.md b/README.md index 8677837..240ccf8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ Build intelligent AI agents in Ruby with a clean DSL, automatic execution tracki - **Rails-Native** - Seamlessly integrates with your Rails app: models, jobs, caching, and Hotwire - **Production-Ready** - Built-in retries, model fallbacks, circuit breakers, and budget limits - **Full Observability** - Track every execution with costs, tokens, duration, and errors -- **Workflow Orchestration** - Compose agents into pipelines, parallel tasks, and conditional routers - **Zero Lock-in** - Works with any LLM provider supported by RubyLLM ## Show Me the Code @@ -32,13 +31,12 @@ class SearchIntentAgent < ApplicationAgent model "gpt-4o" temperature 0.0 - param :query, required: true + # Prompts with {placeholder} syntax - params auto-registered + system "You are a search intent analyzer. Extract structured data from queries." + prompt "Extract search intent from: {query}" - def user_prompt - "Extract search intent from: #{query}" - end - - schema do + # Structured output with returns DSL + returns do string :refined_query, description: "Cleaned search query" array :filters, of: :string, description: "Extracted filters" end @@ -69,17 +67,13 @@ result = ChatAgent.call( class ReliableAgent < ApplicationAgent model "gpt-4o" - reliability do - retries max: 3, backoff: :exponential - fallback_models "gpt-4o-mini", "claude-3-5-sonnet" - circuit_breaker errors: 10, within: 60, cooldown: 300 - total_timeout 30 - end - - param :query, required: true + prompt "{query}" - def user_prompt - query + on_failure do + retries times: 3, backoff: :exponential + fallback to: ["gpt-4o-mini", "claude-3-5-sonnet"] + circuit_breaker after: 10, within: 60, cooldown: 5.minutes + timeout 30 end end ``` @@ -122,41 +116,6 @@ result.url # => "https://..." result.save("logo.png") ``` -```ruby -# Workflow orchestration - sequential, parallel, routing in one DSL -class OrderWorkflow < RubyLLM::Agents::Workflow - description "End-to-end order processing" - timeout 60.seconds - max_cost 1.50 - - input do - required :order_id, String - optional :priority, String, default: "normal" - end - - step :validate, ValidatorAgent - step :enrich, EnricherAgent, input: -> { { data: validate.content } } - - parallel :analysis do - step :sentiment, SentimentAgent, optional: true - step :classify, ClassifierAgent - end - - step :handle, on: -> { classify.category } do |route| - route.billing BillingAgent - route.technical TechnicalAgent - route.default GeneralAgent - end - - step :format, FormatterAgent, optional: true -end - -result = OrderWorkflow.call(order_id: "123") -result.steps[:classify].content # Individual step result -result.total_cost # Sum of all steps -result.success? # true/false -``` - ## Features | Feature | Description | Docs | @@ -167,14 +126,11 @@ result.success? # true/false | **Reliability** | Automatic retries, model fallbacks, circuit breakers with block DSL | [Reliability](https://github.com/adham90/ruby_llm-agents/wiki/Reliability) | | **Budget Controls** | Daily/monthly limits with hard and soft enforcement | [Budgets](https://github.com/adham90/ruby_llm-agents/wiki/Budget-Controls) | | **Multi-Tenancy** | Per-tenant API keys, budgets, circuit breakers, and execution isolation | [Multi-Tenancy](https://github.com/adham90/ruby_llm-agents/wiki/Multi-Tenancy) | -| **Workflows** | Pipelines, parallel execution, conditional routers | [Workflows](https://github.com/adham90/ruby_llm-agents/wiki/Workflows) | | **Async/Fiber** | Concurrent execution with Ruby fibers for high-throughput workloads | [Async](https://github.com/adham90/ruby_llm-agents/wiki/Async-Fiber) | | **Dashboard** | Real-time Turbo-powered monitoring UI | [Dashboard](https://github.com/adham90/ruby_llm-agents/wiki/Dashboard) | | **Streaming** | Real-time response streaming with TTFT tracking | [Streaming](https://github.com/adham90/ruby_llm-agents/wiki/Streaming) | | **Conversation History** | Multi-turn conversations with message history | [Conversation History](https://github.com/adham90/ruby_llm-agents/wiki/Conversation-History) | | **Attachments** | Images, PDFs, and multimodal support | [Attachments](https://github.com/adham90/ruby_llm-agents/wiki/Attachments) | -| **PII Redaction** | Automatic sensitive data protection | [Security](https://github.com/adham90/ruby_llm-agents/wiki/PII-Redaction) | -| **Content Moderation** | Input/output safety checks with OpenAI moderation API | [Moderation](https://github.com/adham90/ruby_llm-agents/wiki/Moderation) | | **Embeddings** | Vector embeddings with batching, caching, and preprocessing | [Embeddings](https://github.com/adham90/ruby_llm-agents/wiki/Embeddings) | | **Image Operations** | Generation, analysis, editing, pipelines with cost tracking | [Images](https://github.com/adham90/ruby_llm-agents/wiki/Image-Generation) | | **Alerts** | Slack, webhook, and custom notifications | [Alerts](https://github.com/adham90/ruby_llm-agents/wiki/Alerts) | @@ -231,13 +187,11 @@ mount RubyLLM::Agents::Engine => "/agents" | [Getting Started](https://github.com/adham90/ruby_llm-agents/wiki/Getting-Started) | Installation, configuration, first agent | | [Agent DSL](https://github.com/adham90/ruby_llm-agents/wiki/Agent-DSL) | All DSL options: model, temperature, params, caching, description | | [Reliability](https://github.com/adham90/ruby_llm-agents/wiki/Reliability) | Retries, fallbacks, circuit breakers, timeouts, reliability block | -| [Workflows](https://github.com/adham90/ruby_llm-agents/wiki/Workflows) | Pipelines, parallel execution, routers | | [Budget Controls](https://github.com/adham90/ruby_llm-agents/wiki/Budget-Controls) | Spending limits, alerts, enforcement | | [Multi-Tenancy](https://github.com/adham90/ruby_llm-agents/wiki/Multi-Tenancy) | Per-tenant budgets, isolation, configuration | | [Async/Fiber](https://github.com/adham90/ruby_llm-agents/wiki/Async-Fiber) | Concurrent execution with Ruby fibers | | [Testing Agents](https://github.com/adham90/ruby_llm-agents/wiki/Testing-Agents) | RSpec patterns, mocking, dry_run mode | | [Error Handling](https://github.com/adham90/ruby_llm-agents/wiki/Error-Handling) | Error types, recovery patterns | -| [Moderation](https://github.com/adham90/ruby_llm-agents/wiki/Moderation) | Content moderation for input/output safety | | [Embeddings](https://github.com/adham90/ruby_llm-agents/wiki/Embeddings) | Vector embeddings, batching, caching, preprocessing | | [Image Generation](https://github.com/adham90/ruby_llm-agents/wiki/Image-Generation) | Text-to-image, templates, content policy, cost tracking | | [Dashboard](https://github.com/adham90/ruby_llm-agents/wiki/Dashboard) | Setup, authentication, analytics | diff --git a/app/controllers/concerns/ruby_llm/agents/sortable.rb b/app/controllers/concerns/ruby_llm/agents/sortable.rb index fed9c1d..c568eb4 100644 --- a/app/controllers/concerns/ruby_llm/agents/sortable.rb +++ b/app/controllers/concerns/ruby_llm/agents/sortable.rb @@ -22,7 +22,6 @@ module Sortable "agent_type" => "agent_type", "status" => "status", "model_id" => "model_id", - "agent_version" => "agent_version", "total_tokens" => "total_tokens", "total_cost" => "total_cost", "duration_ms" => "duration_ms", diff --git a/app/controllers/ruby_llm/agents/agents_controller.rb b/app/controllers/ruby_llm/agents/agents_controller.rb index 1ec8a4e..ad5e042 100644 --- a/app/controllers/ruby_llm/agents/agents_controller.rb +++ b/app/controllers/ruby_llm/agents/agents_controller.rb @@ -48,7 +48,6 @@ def index @agents_by_type = { agent: @agents.select { |a| a[:agent_type] == "agent" }, embedder: @agents.select { |a| a[:agent_type] == "embedder" }, - moderator: @agents.select { |a| a[:agent_type] == "moderator" }, speaker: @agents.select { |a| a[:agent_type] == "speaker" }, transcriber: @agents.select { |a| a[:agent_type] == "transcriber" }, image_generator: @agents.select { |a| a[:agent_type] == "image_generator" } @@ -60,7 +59,7 @@ def index Rails.logger.error("[RubyLLM::Agents] Error loading agents: #{e.message}") @agents = [] @deleted_agents = [] - @agents_by_type = { agent: [], embedder: [], moderator: [], speaker: [], transcriber: [], image_generator: [] } + @agents_by_type = { agent: [], embedder: [], speaker: [], transcriber: [], image_generator: [] } @agent_count = 0 @deleted_count = 0 @sort_params = { column: DEFAULT_AGENT_SORT_COLUMN, direction: DEFAULT_AGENT_SORT_DIRECTION } @@ -119,13 +118,11 @@ def load_agent_stats def load_filter_options # Single query to get all filter options (fixes N+1) filter_data = Execution.by_agent(@agent_type) - .where.not(agent_version: nil) - .or(Execution.by_agent(@agent_type).where.not(model_id: nil)) + .where.not(model_id: nil) .or(Execution.by_agent(@agent_type).where.not(temperature: nil)) - .pluck(:agent_version, :model_id, :temperature) + .pluck(:model_id, :temperature) - @versions = filter_data.map(&:first).compact.uniq.sort.reverse - @models = filter_data.map { |d| d[1] }.compact.uniq.sort + @models = filter_data.map(&:first).compact.uniq.sort @temperatures = filter_data.map(&:last).compact.uniq.sort end @@ -149,7 +146,7 @@ def load_filtered_executions # Builds a filtered scope for the current agent's executions # - # Applies filters in order: status, version, model, temperature, time. + # Applies filters in order: status, model, temperature, time. # Each filter is optional and only applied if values are provided. # # @return [ActiveRecord::Relation] Filtered execution scope @@ -160,10 +157,6 @@ def build_filtered_scope statuses = parse_array_param(:statuses) scope = apply_status_filter(scope, statuses) if statuses.any? - # Apply version filter - versions = parse_array_param(:versions) - scope = scope.where(agent_version: versions) if versions.any? - # Apply model filter models = parse_array_param(:models) scope = scope.where(model_id: models) if models.any? @@ -188,37 +181,6 @@ def load_chart_data @trend_data = Execution.trend_analysis(agent_type: @agent_type, days: 30) @status_distribution = Execution.by_agent(@agent_type).group(:status).count @finish_reason_distribution = Execution.by_agent(@agent_type).finish_reason_distribution - load_version_comparison - end - - # Loads version comparison data if multiple versions exist - # - # Includes trend data for sparkline charts. - # - # @return [void] - def load_version_comparison - return unless @versions.size >= 2 - - # Default to comparing two most recent versions - v1 = params[:compare_v1] || @versions[0] - v2 = params[:compare_v2] || @versions[1] - - comparison_data = Execution.compare_versions(@agent_type, v1, v2, period: :this_month) - - # Fetch trend data for sparklines - v1_trend = Execution.version_trend_data(@agent_type, v1, days: 14) - v2_trend = Execution.version_trend_data(@agent_type, v2, days: 14) - - @version_comparison = { - v1: v1, - v2: v2, - data: comparison_data, - v1_trend: v1_trend, - v2_trend: v2_trend - } - rescue StandardError => e - Rails.logger.debug("[RubyLLM::Agents] Version comparison error: #{e.message}") - @version_comparison = nil end # Loads the current agent class configuration @@ -234,7 +196,6 @@ def load_agent_config # Common config for all types @config = { model: safe_config_call(:model), - version: safe_config_call(:version) || "N/A", description: safe_config_call(:description) } @@ -242,8 +203,6 @@ def load_agent_config case @agent_type_kind when "embedder" load_embedder_config - when "moderator" - load_moderator_config when "speaker" load_speaker_config when "transcriber" @@ -284,16 +243,6 @@ def load_embedder_config ) end - # Loads configuration specific to Moderators - # - # @return [void] - def load_moderator_config - @config.merge!( - threshold: safe_config_call(:threshold), - categories: safe_config_call(:categories) - ) - end - # Loads configuration specific to Speakers # # @return [void] diff --git a/app/controllers/ruby_llm/agents/api_configurations_controller.rb b/app/controllers/ruby_llm/agents/api_configurations_controller.rb deleted file mode 100644 index 674bab9..0000000 --- a/app/controllers/ruby_llm/agents/api_configurations_controller.rb +++ /dev/null @@ -1,214 +0,0 @@ -# frozen_string_literal: true - -module RubyLLM - module Agents - # Controller for managing API configurations - # - # Provides CRUD operations for global and tenant-specific API - # configurations, including API keys and connection settings. - # - # @see ApiConfiguration - # @api private - class ApiConfigurationsController < ApplicationController - before_action :ensure_table_exists - before_action :set_global_config, only: [:show, :edit, :update] - before_action :set_tenant_config, only: [:tenant, :edit_tenant, :update_tenant] - - # Displays the global API configuration - # - # @return [void] - def show - @resolved = ApiConfiguration.resolve - @provider_statuses = @resolved.provider_statuses_with_source - end - - # Renders the edit form for global configuration - # - # @return [void] - def edit - @resolved = ApiConfiguration.resolve - end - - # Updates the global API configuration - # - # @return [void] - def update - if @config.update(api_configuration_params) - log_configuration_change(@config, "global") - redirect_to edit_api_configuration_path, notice: "API configuration updated successfully" - else - render :edit, status: :unprocessable_entity - end - end - - # Displays tenant-specific API configuration - # - # @return [void] - def tenant - @resolved = ApiConfiguration.resolve(tenant_id: params[:tenant_id]) - @provider_statuses = @resolved.provider_statuses_with_source - @tenant_budget = TenantBudget.for_tenant(params[:tenant_id]) - end - - # Renders the edit form for tenant configuration - # - # @return [void] - def edit_tenant - @resolved = ApiConfiguration.resolve(tenant_id: @tenant_id) - end - - # Updates a tenant-specific API configuration - # - # @return [void] - def update_tenant - if @config.update(api_configuration_params) - log_configuration_change(@config, "tenant:#{params[:tenant_id]}") - redirect_to edit_tenant_api_configuration_path(params[:tenant_id]), - notice: "Tenant API configuration updated successfully" - else - render :edit_tenant, status: :unprocessable_entity - end - end - - # Tests API key validity for a specific provider - # (Optional - can be used for AJAX validation) - # - # @return [void] - def test_connection - provider = params[:provider] - api_key = params[:api_key] - - result = test_provider_connection(provider, api_key) - - render json: { - success: result[:success], - message: result[:message], - models: result[:models] - } - rescue StandardError => e - render json: { success: false, message: e.message } - end - - private - - # Ensures the api_configurations table exists - def ensure_table_exists - return if ApiConfiguration.table_exists? - - flash[:alert] = "API configurations table not found. Run the generator: rails generate ruby_llm_agents:api_configuration" - redirect_to root_path - end - - # Sets the global configuration (creates if not exists) - def set_global_config - @config = ApiConfiguration.global - end - - # Sets the tenant-specific configuration (creates if not exists) - def set_tenant_config - @tenant_id = params[:tenant_id] - raise ActionController::RoutingError, "Tenant ID required" if @tenant_id.blank? - - @config = ApiConfiguration.for_tenant!(@tenant_id) - end - - # Strong parameters for API configuration - # - # @return [ActionController::Parameters] - def api_configuration_params - params.require(:api_configuration).permit( - # API Keys - :openai_api_key, - :anthropic_api_key, - :gemini_api_key, - :deepseek_api_key, - :mistral_api_key, - :perplexity_api_key, - :openrouter_api_key, - :gpustack_api_key, - :xai_api_key, - :ollama_api_key, - # AWS Bedrock - :bedrock_api_key, - :bedrock_secret_key, - :bedrock_session_token, - :bedrock_region, - # Vertex AI - :vertexai_credentials, - :vertexai_project_id, - :vertexai_location, - # Endpoints - :openai_api_base, - :gemini_api_base, - :ollama_api_base, - :gpustack_api_base, - :xai_api_base, - # OpenAI Options - :openai_organization_id, - :openai_project_id, - # Default Models - :default_model, - :default_embedding_model, - :default_image_model, - :default_moderation_model, - # Connection Settings - :request_timeout, - :max_retries, - :retry_interval, - :retry_backoff_factor, - :retry_interval_randomness, - :http_proxy, - # Inheritance - :inherit_global_defaults - ).tap do |permitted| - # Remove blank API keys to prevent overwriting with empty values - # This allows users to submit forms without touching existing keys - ApiConfiguration::API_KEY_ATTRIBUTES.each do |key| - permitted.delete(key) if permitted[key].blank? - end - end - end - - # Logs configuration changes for audit purposes - # - # @param config [ApiConfiguration] The configuration that changed - # @param scope [String] The scope identifier - def log_configuration_change(config, scope) - changed_fields = config.previous_changes.keys.reject { |k| k.end_with?("_at") } - return if changed_fields.empty? - - # Mask sensitive fields in the log - masked_changes = changed_fields.map do |field| - if field.include?("api_key") || field.include?("secret") || field.include?("credentials") - "#{field}: [REDACTED]" - else - "#{field}: #{config.previous_changes[field].last}" - end - end - - Rails.logger.info( - "[RubyLLM::Agents] API configuration updated for #{scope}: #{masked_changes.join(', ')}" - ) - end - - # Tests connection to a specific provider - # - # @param provider [String] Provider key - # @param api_key [String] API key to test - # @return [Hash] Test result with success, message, and models - def test_provider_connection(provider, api_key) - # This is a placeholder - actual implementation would depend on - # RubyLLM's ability to list models or make a test request - case provider - when "openai" - # Example: Try to list models - { success: true, message: "Connection successful", models: [] } - when "anthropic" - { success: true, message: "Connection successful", models: [] } - else - { success: false, message: "Provider not supported for testing" } - end - end - end - end -end diff --git a/app/controllers/ruby_llm/agents/dashboard_controller.rb b/app/controllers/ruby_llm/agents/dashboard_controller.rb index ee711e0..42d331c 100644 --- a/app/controllers/ruby_llm/agents/dashboard_controller.rb +++ b/app/controllers/ruby_llm/agents/dashboard_controller.rb @@ -32,37 +32,11 @@ def index # Returns chart data as JSON for live updates # - # @param range [String] Time range: "today", "7d", "30d", "60d", "90d", or custom "YYYY-MM-DD_YYYY-MM-DD" - # @param compare [String] If "true", include comparison data from previous period - # @return [JSON] Chart data with series (and optional comparison series) + # @param range [String] Time range: "today", "7d", or "30d" + # @return [JSON] Chart data with series def chart_data range = params[:range].presence || "today" - compare = params[:compare] == "true" - - if custom_range?(range) - from_date, to_date = parse_custom_range(range) - data = tenant_scoped_executions.activity_chart_json_for_dates(from: from_date, to: to_date) - else - data = tenant_scoped_executions.activity_chart_json(range: range) - end - - if compare - offset_days = range_to_days(range) - comparison_data = if custom_range?(range) - from_date, to_date = parse_custom_range(range) - tenant_scoped_executions.activity_chart_json_for_dates( - from: from_date - offset_days.days, - to: to_date - offset_days.days - ) - else - tenant_scoped_executions.activity_chart_json( - range: range, - offset_days: offset_days - ) - end - data[:comparison] = comparison_data - end - + data = tenant_scoped_executions.activity_chart_json(range: range) render json: data end @@ -70,49 +44,17 @@ def chart_data # Converts range parameter to number of days # - # @param range [String] Range parameter (today, 7d, 30d, 60d, 90d, or custom YYYY-MM-DD_YYYY-MM-DD) + # @param range [String] Range parameter (today, 7d, 30d) # @return [Integer] Number of days def range_to_days(range) case range when "today" then 1 when "7d" then 7 when "30d" then 30 - when "60d" then 60 - when "90d" then 90 - else - # Handle custom range format "YYYY-MM-DD_YYYY-MM-DD" - if range&.include?("_") - from_str, to_str = range.split("_") - from_date = Date.parse(from_str) rescue nil - to_date = Date.parse(to_str) rescue nil - if from_date && to_date - (to_date - from_date).to_i + 1 - else - 1 - end - else - 1 - end + else 1 end end - # Checks if a range is a custom date range - # - # @param range [String] Range parameter - # @return [Boolean] True if custom date range format - def custom_range?(range) - range&.match?(/\A\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\z/) - end - - # Parses a custom range string into date objects - # - # @param range [String] Custom range in format "YYYY-MM-DD_YYYY-MM-DD" - # @return [Array] [from_date, to_date] - def parse_custom_range(range) - from_str, to_str = range.split("_") - [Date.parse(from_str), Date.parse(to_str)] - end - # Builds per-agent comparison statistics for all agent types # # Creates separate instance variables for each agent type: @@ -121,8 +63,6 @@ def parse_custom_range(range) # - @transcriber_stats: Transcribers # - @speaker_stats: Speakers # - @image_generator_stats: Image generators - # - @moderator_stats: Moderators - # - @workflow_stats: Workflows # # @param base_scope [ActiveRecord::Relation] Base scope to filter from # @return [Array] Array of base agent stats (for backward compatibility) @@ -138,7 +78,6 @@ def build_agent_comparison(base_scope = Execution) all_stats = all_agent_types.map do |agent_type| agent_class = AgentRegistry.find(agent_type) detected_type = AgentRegistry.send(:detect_agent_type, agent_class) - workflow_type = detected_type == "workflow" ? detect_workflow_type(agent_class) : nil # Get stats from batch or use zeros for never-executed agents stats = execution_stats[agent_type] || { @@ -152,43 +91,21 @@ def build_agent_comparison(base_scope = Execution) total_cost: stats[:total_cost], avg_cost: stats[:avg_cost], avg_duration_ms: stats[:avg_duration_ms], - success_rate: stats[:success_rate], - is_workflow: detected_type == "workflow", - workflow_type: workflow_type + success_rate: stats[:success_rate] } end.sort_by { |a| [-(a[:executions] || 0), -(a[:total_cost] || 0)] } - # Split stats by agent type for 7-tab display + # Split stats by agent type for 5-tab display @agent_stats = all_stats.select { |a| a[:detected_type] == "agent" } @embedder_stats = all_stats.select { |a| a[:detected_type] == "embedder" } @transcriber_stats = all_stats.select { |a| a[:detected_type] == "transcriber" } @speaker_stats = all_stats.select { |a| a[:detected_type] == "speaker" } @image_generator_stats = all_stats.select { |a| a[:detected_type] == "image_generator" } - @moderator_stats = all_stats.select { |a| a[:detected_type] == "moderator" } - @workflow_stats = all_stats.select { |a| a[:detected_type] == "workflow" } # Return base agents for backward compatibility @agent_stats end - # Detects workflow type from class hierarchy - # - # @param agent_class [Class] The agent class - # @return [String, nil] "pipeline", "parallel", "router", or nil - def detect_workflow_type(agent_class) - return nil unless agent_class - - ancestors = agent_class.ancestors.map { |a| a.name.to_s } - - if ancestors.include?("RubyLLM::Agents::Workflow::Pipeline") - "pipeline" - elsif ancestors.include?("RubyLLM::Agents::Workflow::Parallel") - "parallel" - elsif ancestors.include?("RubyLLM::Agents::Workflow::Router") - "router" - end - end - # Builds per-model statistics for model comparison and cost breakdown # # @param base_scope [ActiveRecord::Relation] Base scope to filter from diff --git a/app/controllers/ruby_llm/agents/executions_controller.rb b/app/controllers/ruby_llm/agents/executions_controller.rb index 03d8b72..0e51143 100644 --- a/app/controllers/ruby_llm/agents/executions_controller.rb +++ b/app/controllers/ruby_llm/agents/executions_controller.rb @@ -16,7 +16,7 @@ class ExecutionsController < ApplicationController include Filterable include Sortable - CSV_COLUMNS = %w[id agent_type agent_version status model_id total_tokens total_cost + CSV_COLUMNS = %w[id agent_type status model_id total_tokens total_cost duration_ms created_at error_class error_message].freeze # Lists all executions with filtering and pagination @@ -98,7 +98,6 @@ def generate_csv_row(execution) CSV.generate_line([ execution.id, execution.agent_type, - execution.agent_version, execution.status, execution.model_id, execution.total_tokens, @@ -113,14 +112,12 @@ def generate_csv_row(execution) # Loads available options for filter dropdowns # # Populates @agent_types with all agent types that have executions, - # @model_ids with all distinct models used, @workflow_types with - # workflow patterns used, and @statuses with all possible status values. + # @model_ids with all distinct models used, and @statuses with all possible status values. # # @return [void] def load_filter_options @agent_types = available_agent_types @model_ids = available_model_ids - @workflow_types = available_workflow_types @statuses = Execution.statuses.keys end @@ -144,24 +141,6 @@ def available_model_ids @available_model_ids ||= tenant_scoped_executions.where.not(model_id: nil).distinct.pluck(:model_id).sort end - # Returns distinct workflow types from execution history - # - # Memoized to avoid duplicate queries within a request. - # Returns empty array if workflow_type column doesn't exist yet. - # Uses tenant_scoped_executions to respect multi-tenancy filtering. - # - # @return [Array] Workflow types (pipeline, parallel, router) - def available_workflow_types - return @available_workflow_types if defined?(@available_workflow_types) - - @available_workflow_types = if Execution.column_names.include?("workflow_type") - tenant_scoped_executions.where.not(workflow_type: [nil, ""]) - .distinct.pluck(:workflow_type).sort - else - [] - end - end - # Loads paginated executions and associated statistics # # Sets @executions, @pagination, @sort_params, and @filter_stats instance variables @@ -221,63 +200,17 @@ def filtered_executions model_ids = parse_array_param(:model_ids) scope = scope.where(model_id: model_ids) if model_ids.any? - # Apply workflow type filter (only if column exists) - if Execution.column_names.include?("workflow_type") - workflow_types = parse_array_param(:workflow_types) - if workflow_types.any? - includes_single = workflow_types.include?("single") - other_types = workflow_types - ["single"] - - if includes_single && other_types.any? - # Include both single (null workflow_type) and specific workflow types - scope = scope.where(workflow_type: [nil, ""] + other_types) - elsif includes_single - # Only single executions (non-workflow) - scope = scope.where(workflow_type: [nil, ""]) - else - # Only specific workflow types - scope = scope.where(workflow_type: workflow_types) - end - end - end - - # Apply execution type tab filter (agents vs workflows) - scope = apply_execution_type_filter(scope) - # Apply retries filter (show only executions with multiple attempts) scope = scope.where("attempts_count > 1") if params[:has_retries].present? - # Only show root executions (not workflow children) - children are nested under parents + # Only show root executions - children are nested under parents scope = scope.where(parent_execution_id: nil) - # Eager load children for workflow grouping + # Eager load children for grouping scope = scope.includes(:child_executions) scope end - - # Applies execution type filter (all, agents, workflows, or specific workflow type) - # - # @param scope [ActiveRecord::Relation] The current scope - # @return [ActiveRecord::Relation] Filtered scope - def apply_execution_type_filter(scope) - return scope unless Execution.column_names.include?("workflow_type") - - execution_type = params[:execution_type] - case execution_type - when "agents" - # Only show executions where workflow_type is null/empty (regular agents) - scope.where(workflow_type: [nil, ""]) - when "workflows" - # Only show executions with a workflow_type (any workflow) - scope.where.not(workflow_type: [nil, ""]) - when "pipeline", "parallel", "router" - # Show specific workflow type - scope.where(workflow_type: execution_type) - else - scope - end - end end end end diff --git a/app/controllers/ruby_llm/agents/tenants_controller.rb b/app/controllers/ruby_llm/agents/tenants_controller.rb index 6317656..9a2a62e 100644 --- a/app/controllers/ruby_llm/agents/tenants_controller.rb +++ b/app/controllers/ruby_llm/agents/tenants_controller.rb @@ -10,11 +10,26 @@ module Agents # @see TenantBudget For budget configuration model # @api private class TenantsController < ApplicationController - # Lists all tenant budgets + TENANT_SORTABLE_COLUMNS = %w[name enforcement daily_limit monthly_limit].freeze + DEFAULT_TENANT_SORT_COLUMN = "name" + DEFAULT_TENANT_SORT_DIRECTION = "asc" + + # Lists all tenant budgets with optional search and sorting # # @return [void] def index - @tenants = TenantBudget.order(:name, :tenant_id) + @sort_params = parse_tenant_sort_params + scope = TenantBudget.all + + if params[:q].present? + @search_query = params[:q].to_s.strip + scope = scope.where( + "tenant_id LIKE :q OR name LIKE :q", + q: "%#{TenantBudget.sanitize_sql_like(@search_query)}%" + ) + end + + @tenants = scope.order(@sort_params[:column] => @sort_params[:direction].to_sym) end # Shows a single tenant's budget details @@ -95,6 +110,19 @@ def calculate_usage_stats(tenant) } end + # Parses and validates sort parameters for tenants list + # + # @return [Hash] Contains :column and :direction keys + def parse_tenant_sort_params + column = params[:sort].to_s + direction = params[:direction].to_s.downcase + + { + column: TENANT_SORTABLE_COLUMNS.include?(column) ? column : DEFAULT_TENANT_SORT_COLUMN, + direction: %w[asc desc].include?(direction) ? direction : DEFAULT_TENANT_SORT_DIRECTION + } + end + # Calculates percentage used # # @param current [Numeric] Current usage diff --git a/app/controllers/ruby_llm/agents/workflows_controller.rb b/app/controllers/ruby_llm/agents/workflows_controller.rb deleted file mode 100644 index 3689119..0000000 --- a/app/controllers/ruby_llm/agents/workflows_controller.rb +++ /dev/null @@ -1,544 +0,0 @@ -# frozen_string_literal: true - -module RubyLLM - module Agents - # Controller for viewing workflow details and per-workflow analytics - # - # Provides detailed views for individual workflows including structure - # visualization, per-step/branch performance, route distribution, - # and execution history. - # - # @see AgentRegistry For workflow discovery - # @see Paginatable For pagination implementation - # @see Filterable For filter parsing and validation - # @api private - class WorkflowsController < ApplicationController - include Paginatable - include Filterable - - # Lists all registered workflows with their details - # - # Uses AgentRegistry to discover workflows from both file system - # and execution history. Separates workflows by type for sub-tabs. - # Supports sorting by various columns. - # - # @return [void] - def index - all_items = AgentRegistry.all_with_details - @workflows = all_items.select { |a| a[:is_workflow] } - @sort_params = parse_sort_params - @workflows = sort_workflows(@workflows) - rescue StandardError => e - Rails.logger.error("[RubyLLM::Agents] Error loading workflows: #{e.message}") - @workflows = [] - flash.now[:alert] = "Error loading workflows list" - end - - # Shows detailed view for a specific workflow - # - # Loads workflow configuration (if class exists), statistics, - # filtered executions, chart data, and step-level analytics. - # Works for both active workflows and deleted workflows with history. - # - # @return [void] - def show - @workflow_type = CGI.unescape(params[:id]) - @workflow_class = AgentRegistry.find(@workflow_type) - @workflow_active = @workflow_class.present? - - # Determine workflow type from class or execution history - @workflow_type_kind = detect_workflow_type_kind - - load_workflow_stats - load_filter_options - load_filtered_executions - load_chart_data - load_step_stats - - if @workflow_class - load_workflow_config - end - rescue StandardError => e - Rails.logger.error("[RubyLLM::Agents] Error loading workflow #{@workflow_type}: #{e.message}") - redirect_to ruby_llm_agents.workflows_path, alert: "Error loading workflow details" - end - - private - - # Detects the workflow type kind - # - # All workflows now use the DSL and return "workflow" type. - # - # @return [String, nil] The workflow type kind - def detect_workflow_type_kind - if @workflow_class - if @workflow_class.respond_to?(:step_configs) && @workflow_class.step_configs.any? - "workflow" - end - else - # Fallback to execution history - Execution.by_agent(@workflow_type) - .where.not(workflow_type: nil) - .pluck(:workflow_type) - .first - end - end - - # Loads all-time and today's statistics for the workflow - # - # @return [void] - def load_workflow_stats - @stats = Execution.stats_for(@workflow_type, period: :all_time) - @stats_today = Execution.stats_for(@workflow_type, period: :today) - - # Additional stats for new schema fields - workflow_scope = Execution.by_agent(@workflow_type) - @cache_hit_rate = workflow_scope.cache_hit_rate - @streaming_rate = workflow_scope.streaming_rate - @avg_ttft = workflow_scope.avg_time_to_first_token - end - - # Loads available filter options from execution history - # - # @return [void] - def load_filter_options - filter_data = Execution.by_agent(@workflow_type) - .where.not(agent_version: nil) - .or(Execution.by_agent(@workflow_type).where.not(model_id: nil)) - .or(Execution.by_agent(@workflow_type).where.not(temperature: nil)) - .pluck(:agent_version, :model_id, :temperature) - - @versions = filter_data.map(&:first).compact.uniq.sort.reverse - @models = filter_data.map { |d| d[1] }.compact.uniq.sort - @temperatures = filter_data.map(&:last).compact.uniq.sort - end - - # Loads paginated and filtered executions with statistics - # - # @return [void] - def load_filtered_executions - base_scope = build_filtered_scope - result = paginate(base_scope) - @executions = result[:records] - @pagination = result[:pagination] - - @filter_stats = { - total_count: result[:pagination][:total_count], - total_cost: base_scope.sum(:total_cost), - total_tokens: base_scope.sum(:total_tokens) - } - end - - # Builds a filtered scope for the current workflow's executions - # - # @return [ActiveRecord::Relation] Filtered execution scope - def build_filtered_scope - scope = Execution.by_agent(@workflow_type) - - # Apply status filter with validation - statuses = parse_array_param(:statuses) - scope = apply_status_filter(scope, statuses) if statuses.any? - - # Apply version filter - versions = parse_array_param(:versions) - scope = scope.where(agent_version: versions) if versions.any? - - # Apply model filter - models = parse_array_param(:models) - scope = scope.where(model_id: models) if models.any? - - # Apply temperature filter - temperatures = parse_array_param(:temperatures) - scope = scope.where(temperature: temperatures) if temperatures.any? - - # Apply time range filter with validation - days = parse_days_param - scope = apply_time_filter(scope, days) - - scope - end - - # Loads chart data for workflow performance visualization - # - # @return [void] - def load_chart_data - @trend_data = Execution.trend_analysis(agent_type: @workflow_type, days: 30) - @status_distribution = Execution.by_agent(@workflow_type).group(:status).count - @finish_reason_distribution = Execution.by_agent(@workflow_type).finish_reason_distribution - end - - # Loads per-step/branch statistics for workflow analytics - # - # @return [void] - def load_step_stats - @step_stats = calculate_step_stats - end - - # Calculates per-step/branch performance statistics - # - # @return [Array] Array of step stats hashes - def calculate_step_stats - # Get root workflow executions - root_executions = Execution.by_agent(@workflow_type) - .root_executions - .where("created_at > ?", 30.days.ago) - .pluck(:id) - - return [] if root_executions.empty? - - # Aggregate child execution stats by workflow_step - child_stats = Execution.where(parent_execution_id: root_executions) - .group(:workflow_step) - .select( - "workflow_step", - "COUNT(*) as execution_count", - "AVG(duration_ms) as avg_duration_ms", - "SUM(total_cost) as total_cost", - "AVG(total_cost) as avg_cost", - "SUM(total_tokens) as total_tokens", - "AVG(total_tokens) as avg_tokens", - "SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count", - "SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_count" - ) - - # Get agent type mappings for each step - step_agent_map = Execution.where(parent_execution_id: root_executions) - .where.not(workflow_step: nil) - .group(:workflow_step) - .pluck(:workflow_step, Arel.sql("MAX(agent_type)")) - .to_h - - child_stats.map do |row| - next if row.workflow_step.blank? - - execution_count = row.execution_count.to_i - success_count = row.success_count.to_i - success_rate = execution_count > 0 ? (success_count.to_f / execution_count * 100).round(1) : 0 - - { - name: row.workflow_step, - agent_type: step_agent_map[row.workflow_step], - execution_count: execution_count, - success_rate: success_rate, - avg_duration_ms: row.avg_duration_ms.to_f.round(0), - total_cost: row.total_cost.to_f.round(4), - avg_cost: row.avg_cost.to_f.round(6), - total_tokens: row.total_tokens.to_i, - avg_tokens: row.avg_tokens.to_f.round(0) - } - end.compact - end - - # Loads the current workflow class configuration - # - # @return [void] - def load_workflow_config - @config = { - # Basic configuration - version: safe_call(@workflow_class, :version), - description: safe_call(@workflow_class, :description), - timeout: safe_call(@workflow_class, :timeout), - max_cost: safe_call(@workflow_class, :max_cost) - } - - # Load unified workflow structure for all types - load_unified_workflow_config - end - - # Loads unified workflow configuration for all workflow types - # - # @return [void] - def load_unified_workflow_config - @parallel_groups = [] - @input_schema_fields = {} - @lifecycle_hooks = {} - - # All workflows use DSL - @steps = extract_dsl_steps(@workflow_class) - @parallel_groups = extract_parallel_groups(@workflow_class) - @lifecycle_hooks = extract_lifecycle_hooks(@workflow_class) - - @config[:steps_count] = @steps.size - @config[:parallel_groups_count] = @parallel_groups.size - @config[:has_routing] = @steps.any? { |s| s[:routing] } - @config[:has_conditions] = @steps.any? { |s| s[:if_condition] || s[:unless_condition] } - @config[:has_retries] = @steps.any? { |s| s[:retry_config] } - @config[:has_fallbacks] = @steps.any? { |s| s[:fallbacks]&.any? } - @config[:has_lifecycle_hooks] = @lifecycle_hooks.values.any? { |v| v.to_i > 0 } - @config[:has_input_schema] = @workflow_class.respond_to?(:input_schema) && @workflow_class.input_schema.present? - - if @config[:has_input_schema] - @input_schema_fields = @workflow_class.input_schema.fields.transform_values(&:to_h) - end - end - - # Extracts steps from a DSL-based workflow class with full configuration - # - # @param klass [Class] The workflow class - # @return [Array] Array of step hashes with full DSL metadata - def extract_dsl_steps(klass) - return [] unless klass.respond_to?(:step_metadata) && klass.respond_to?(:step_configs) - - step_configs = klass.step_configs - - klass.step_metadata.map do |meta| - # Handle wait steps - they have type: :wait and aren't in step_configs - if meta[:type] == :wait - { - name: meta[:name], - type: :wait, - wait_type: meta[:wait_type], - ui_label: meta[:ui_label], - timeout: meta[:timeout], - duration: meta[:duration], - poll_interval: meta[:poll_interval], - on_timeout: meta[:on_timeout], - notify: meta[:notify], - approvers: meta[:approvers], - parallel: false - }.compact - else - config = step_configs[meta[:name]] - step_hash = { - name: meta[:name], - agent: meta[:agent], - description: meta[:description], - ui_label: meta[:ui_label], - optional: meta[:optional], - timeout: meta[:timeout], - routing: meta[:routing], - parallel: meta[:parallel], - parallel_group: meta[:parallel_group], - custom_block: config&.custom_block?, - # New composition features - workflow: meta[:workflow], - iteration: meta[:iteration], - iteration_concurrency: meta[:iteration_concurrency] - } - - # Add extended configuration from StepConfig - if config - step_hash.merge!( - retry_config: extract_retry_config(config), - fallbacks: config.fallbacks.map(&:name), - if_condition: describe_condition(config.if_condition), - unless_condition: describe_condition(config.unless_condition), - has_input_mapper: config.input_mapper.present?, - pick_fields: config.pick_fields, - pick_from: config.pick_from, - default_value: config.default_value, - routes: extract_routes(config), - # Iteration error handling - iteration_fail_fast: config.iteration_fail_fast?, - continue_on_error: config.continue_on_error? - ) - - # Add sub-workflow metadata for nested workflow steps - if config.workflow? && config.agent - step_hash[:sub_workflow] = extract_sub_workflow_metadata(config.agent) - end - end - - step_hash.compact - end - end - end - - # Extracts retry configuration in a display-friendly format - # - # @param config [StepConfig] The step configuration - # @return [Hash, nil] Retry config hash or nil - def extract_retry_config(config) - retry_cfg = config.retry_config - return nil unless retry_cfg && retry_cfg[:max].to_i > 0 - - { - max: retry_cfg[:max], - backoff: retry_cfg[:backoff], - delay: retry_cfg[:delay] - } - end - - # Describes a condition for display - # - # @param condition [Symbol, Proc, nil] The condition - # @return [String, nil] Human-readable description - def describe_condition(condition) - return nil if condition.nil? - - case condition - when Symbol then condition.to_s - when Proc then "lambda" - else condition.to_s - end - end - - # Extracts routes from a routing step - # - # @param config [StepConfig] The step configuration - # @return [Array, nil] Array of route hashes or nil - def extract_routes(config) - return nil unless config.routing? && config.block - - builder = RubyLLM::Agents::Workflow::DSL::RouteBuilder.new - config.block.call(builder) - - routes = builder.routes.map do |name, route_config| - { - name: name.to_s, - agent: route_config[:agent]&.name, - timeout: extract_timeout_value(route_config[:options][:timeout]), - fallback: Array(route_config[:options][:fallback]).first&.then { |f| f.respond_to?(:name) ? f.name : f.to_s }, - has_input_mapper: route_config[:options][:input].present?, - if_condition: describe_condition(route_config[:options][:if]), - default: false - }.compact - end - - # Add default route - if builder.default - routes << { - name: "default", - agent: builder.default[:agent]&.name, - timeout: extract_timeout_value(builder.default[:options][:timeout]), - has_input_mapper: builder.default[:options][:input].present?, - default: true - }.compact - end - - routes - rescue StandardError => e - Rails.logger.debug "[RubyLLM::Agents] Could not extract routes: #{e.message}" - nil - end - - # Extracts timeout value handling ActiveSupport::Duration - # - # @param timeout [Integer, ActiveSupport::Duration, nil] The timeout value - # @return [Integer, nil] Timeout in seconds or nil - def extract_timeout_value(timeout) - return nil if timeout.nil? - - timeout.respond_to?(:to_i) ? timeout.to_i : timeout - end - - # Extracts metadata for a nested sub-workflow - # - # @param workflow_class [Class] The sub-workflow class - # @return [Hash] Sub-workflow metadata including steps preview and budget info - def extract_sub_workflow_metadata(workflow_class) - return nil unless workflow_class.respond_to?(:step_metadata) - - { - name: workflow_class.name, - description: safe_call(workflow_class, :description), - timeout: safe_call(workflow_class, :timeout), - max_cost: safe_call(workflow_class, :max_cost), - max_recursion_depth: safe_call(workflow_class, :max_recursion_depth), - steps_count: workflow_class.step_configs.size, - steps_preview: extract_sub_workflow_steps_preview(workflow_class) - }.compact - rescue StandardError => e - Rails.logger.debug "[RubyLLM::Agents] Could not extract sub-workflow metadata: #{e.message}" - nil - end - - # Extracts a simplified steps preview for sub-workflow display - # - # @param workflow_class [Class] The sub-workflow class - # @return [Array] Simplified step hashes for preview - def extract_sub_workflow_steps_preview(workflow_class) - return [] unless workflow_class.respond_to?(:step_metadata) - - workflow_class.step_metadata.map do |meta| - { - name: meta[:name], - agent: meta[:agent]&.gsub(/Agent$/, "")&.gsub(/Workflow$/, ""), - routing: meta[:routing], - iteration: meta[:iteration], - workflow: meta[:workflow], - parallel: meta[:parallel] - }.compact - end - rescue StandardError - [] - end - - # Extracts parallel groups from a DSL-based workflow class - # - # @param klass [Class] The workflow class - # @return [Array] Array of parallel group hashes - def extract_parallel_groups(klass) - return [] unless klass.respond_to?(:parallel_groups) - - klass.parallel_groups.map(&:to_h) - end - - # Extracts lifecycle hooks from a workflow class - # - # @param klass [Class] The workflow class - # @return [Hash] Hash of hook types to counts - def extract_lifecycle_hooks(klass) - return {} unless klass.respond_to?(:lifecycle_hooks) - - hooks = klass.lifecycle_hooks - { - before_workflow: hooks[:before_workflow]&.size || 0, - after_workflow: hooks[:after_workflow]&.size || 0, - on_step_error: hooks[:on_step_error]&.size || 0 - } - end - - # Safely calls a method on a class, returning nil if method doesn't exist - # - # @param klass [Class, nil] The class to call the method on - # @param method_name [Symbol] The method to call - # @return [Object, nil] The result or nil - def safe_call(klass, method_name) - return nil unless klass - return nil unless klass.respond_to?(method_name) - - klass.public_send(method_name) - rescue StandardError - nil - end - - # Parses and validates sort parameters from request - # - # @return [Hash] Hash with :column and :direction keys - def parse_sort_params - allowed_columns = %w[name workflow_type execution_count total_cost success_rate last_executed] - column = params[:sort] - direction = params[:direction] - - { - column: allowed_columns.include?(column) ? column : "name", - direction: %w[asc desc].include?(direction) ? direction : "asc" - } - end - - # Sorts workflows array by the specified column and direction - # - # @param workflows [Array] Array of workflow hashes - # @return [Array] Sorted array - def sort_workflows(workflows) - column = @sort_params[:column].to_sym - direction = @sort_params[:direction] - - sorted = workflows.sort_by do |w| - value = w[column] - case column - when :name - value.to_s.downcase - when :last_executed - value || Time.at(0) - else - value || 0 - end - end - - direction == "desc" ? sorted.reverse : sorted - end - end - end -end diff --git a/app/helpers/ruby_llm/agents/application_helper.rb b/app/helpers/ruby_llm/agents/application_helper.rb index 5a36fad..a704309 100644 --- a/app/helpers/ruby_llm/agents/application_helper.rb +++ b/app/helpers/ruby_llm/agents/application_helper.rb @@ -19,13 +19,10 @@ module ApplicationHelper "dashboard/index" => "Dashboard", "agents/index" => "Agent-DSL", "agents/show" => "Agent-DSL", - "workflows/index" => "Workflows", - "workflows/show" => "Workflows", "executions/index" => "Execution-Tracking", "executions/show" => "Execution-Tracking", "tenants/index" => "Multi-Tenancy", - "system_config/show" => "Configuration", - "api_configurations/show" => "Configuration" + "system_config/show" => "Configuration" }.freeze # Returns the documentation URL for the current page or a specific page key @@ -59,27 +56,11 @@ def ruby_llm_agents # Returns the URL for "All Tenants" (clears tenant filter) # - # Handles two scenarios: - # 1. Query param routes - removes tenant_id from query params - # 2. Path-based tenant routes - navigates to equivalent global route + # Removes tenant_id from query params to show unfiltered results. # # @return [String] URL without tenant filtering def all_tenants_url - # Map tenant-specific path routes to their global equivalents - tenant_route_mappings = { - "tenant" => ruby_llm_agents.api_configuration_path, - "edit_tenant" => ruby_llm_agents.edit_api_configuration_path - } - - # Check if current action has a global equivalent - if tenant_route_mappings.key?(action_name) - base_path = tenant_route_mappings[action_name] - query = request.query_parameters.except("tenant_id") - query.any? ? "#{base_path}?#{query.to_query}" : base_path - else - # For query param routes, just remove tenant_id - url_for(request.query_parameters.except("tenant_id")) - end + url_for(request.query_parameters.except("tenant_id")) end # Formats large numbers with human-readable suffixes (K, M, B) @@ -121,7 +102,7 @@ def number_to_human_short(number, prefix: nil, precision: 1) # @return [ActiveSupport::SafeBuffer] HTML badge element def render_enabled_badge(enabled) if enabled - 'Enabled'.html_safe + 'Enabled'.html_safe else 'Disabled'.html_safe end @@ -133,7 +114,7 @@ def render_enabled_badge(enabled) # @return [ActiveSupport::SafeBuffer] HTML badge element def render_configured_badge(configured) if configured - 'Configured'.html_safe + 'Configured'.html_safe else 'Not configured'.html_safe end @@ -254,7 +235,7 @@ def comparison_badge(change_pct, metric_type) end if is_improvement - content_tag(:span, class: "inline-flex items-center gap-1 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") do + content_tag(:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-500/20 rounded-full") do safe_join([ content_tag(:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 10l7-7m0 0l7 7m-7-7v18") @@ -263,7 +244,7 @@ def comparison_badge(change_pct, metric_type) ]) end elsif is_regression - content_tag(:span, class: "inline-flex items-center gap-1 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") do + content_tag(:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-500/20 rounded-full") do safe_join([ content_tag(:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M19 14l-7 7m0 0l-7-7m7 7V3") @@ -306,31 +287,16 @@ def comparison_indicator(change_pct, metric_type: :count) # Returns human-readable display name for time range # - # @param range [String] Range parameter (today, 7d, 30d, 60d, 90d, or custom YYYY-MM-DD_YYYY-MM-DD) + # @param range [String] Range parameter (today, 7d, 30d) # @return [String] Human-readable range name # @example - # range_display_name("7d") #=> "Last 7 Days" - # range_display_name("2024-01-01_2024-01-15") #=> "Jan 1 - Jan 15" + # range_display_name("7d") #=> "7 Days" def range_display_name(range) case range when "today" then "Today" - when "7d" then "Last 7 Days" - when "30d" then "Last 30 Days" - when "60d" then "Last 60 Days" - when "90d" then "Last 90 Days" - else - if range&.include?("_") - from_str, to_str = range.split("_") - from_date = Date.parse(from_str) rescue nil - to_date = Date.parse(to_str) rescue nil - if from_date && to_date - "#{from_date.strftime('%b %-d')} - #{to_date.strftime('%b %-d')}" - else - "Custom Range" - end - else - "Today" - end + when "7d" then "7 Days" + when "30d" then "30 Days" + else "Today" end end @@ -391,7 +357,7 @@ def comparison_row_class(change_pct, metric_type) # @return [ActiveSupport::SafeBuffer] HTML summary banner def comparison_summary_badge(improvements_count, regressions_count, v2_label) if improvements_count >= 3 && regressions_count == 0 - content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/50 rounded-lg") do + content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-500/20 rounded-lg") do safe_join([ content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do content_tag(:path, nil, "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") @@ -400,7 +366,7 @@ def comparison_summary_badge(improvements_count, regressions_count, v2_label) ]) end elsif regressions_count >= 3 && improvements_count == 0 - content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900/50 rounded-lg") do + content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-500/20 rounded-lg") do safe_join([ content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do content_tag(:path, nil, "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") @@ -409,7 +375,7 @@ def comparison_summary_badge(improvements_count, regressions_count, v2_label) ]) end elsif improvements_count > 0 || regressions_count > 0 - content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-amber-700 dark:text-amber-300 bg-amber-100 dark:bg-amber-900/50 rounded-lg") do + content_tag(:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-amber-700 dark:text-amber-300 bg-amber-100 dark:bg-amber-500/20 rounded-lg") do safe_join([ content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4") @@ -552,14 +518,14 @@ def highlight_json_string(json_string) escaped_value = ERB::Util.html_escape(token[:value]) if is_key - result << %(#{escaped_value}) + result << %(#{escaped_value}) else - result << %(#{escaped_value}) + result << %(#{escaped_value}) end when :number - result << %(#{token[:value]}) + result << %(#{token[:value]}) when :boolean - result << %(#{token[:value]}) + result << %(#{token[:value]}) when :null result << %(#{token[:value]}) else diff --git a/app/mailers/ruby_llm/agents/alert_mailer.rb b/app/mailers/ruby_llm/agents/alert_mailer.rb deleted file mode 100644 index eeb4a27..0000000 --- a/app/mailers/ruby_llm/agents/alert_mailer.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -module RubyLLM - module Agents - # Mailer for sending alert notifications via email - # - # Delivers alert notifications when important events occur like - # budget exceedance or circuit breaker activation. - # - # @example Sending an alert email - # AlertMailer.alert_notification( - # event: :budget_hard_cap, - # payload: { limit: 100.0, total: 105.0 }, - # recipient: "admin@example.com" - # ).deliver_later - # - # @api public - class AlertMailer < ApplicationMailer - # Sends an alert notification email - # - # @param event [Symbol] The event type (e.g., :budget_soft_cap, :breaker_open) - # @param payload [Hash] Event-specific data - # @param recipient [String] Email address of the recipient - # @return [Mail::Message] - def alert_notification(event:, payload:, recipient:) - @event = event - @payload = payload - @title = event_title(event) - @severity = event_severity(event) - @color = event_color(event) - @timestamp = Time.current - - mail( - to: recipient, - subject: "[RubyLLM::Agents Alert] #{@title}" - ) - end - - private - - # Returns human-readable title for event type - # - # @param event [Symbol] The event type - # @return [String] Human-readable title - def event_title(event) - case event - when :budget_soft_cap then "Budget Soft Cap Reached" - when :budget_hard_cap then "Budget Hard Cap Exceeded" - when :breaker_open then "Circuit Breaker Opened" - when :agent_anomaly then "Agent Anomaly Detected" - else event.to_s.titleize - end - end - - # Returns severity level for event type - # - # @param event [Symbol] The event type - # @return [String] Severity level - def event_severity(event) - case event - when :budget_soft_cap then "Warning" - when :budget_hard_cap then "Critical" - when :breaker_open then "Critical" - when :agent_anomaly then "Warning" - else "Info" - end - end - - # Returns color for event type - # - # @param event [Symbol] The event type - # @return [String] Hex color code - def event_color(event) - case event - when :budget_soft_cap then "#FFA500" # Orange - when :budget_hard_cap then "#FF0000" # Red - when :breaker_open then "#FF0000" # Red - when :agent_anomaly then "#FFA500" # Orange - else "#0000FF" # Blue - end - end - end - end -end diff --git a/app/mailers/ruby_llm/agents/application_mailer.rb b/app/mailers/ruby_llm/agents/application_mailer.rb deleted file mode 100644 index 436f380..0000000 --- a/app/mailers/ruby_llm/agents/application_mailer.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module RubyLLM - module Agents - # Base mailer class for RubyLLM::Agents - # - # Host application must configure ActionMailer with SMTP settings - # for email delivery to work. - # - # @api private - class ApplicationMailer < ::ActionMailer::Base - default from: -> { default_from_address } - - layout false # Templates are self-contained - - private - - def default_from_address - RubyLLM::Agents.configuration.alerts&.dig(:email_from) || - "noreply@#{default_host}" - end - - def default_host - ::ActionMailer::Base.default_url_options[:host] || "example.com" - end - end - end -end diff --git a/app/models/ruby_llm/agents/api_configuration.rb b/app/models/ruby_llm/agents/api_configuration.rb deleted file mode 100644 index 2482f51..0000000 --- a/app/models/ruby_llm/agents/api_configuration.rb +++ /dev/null @@ -1,386 +0,0 @@ -# frozen_string_literal: true - -module RubyLLM - module Agents - # Database-backed API configuration for LLM providers - # - # Stores API keys (encrypted at rest) and configuration options that can be - # managed via the dashboard UI. Supports both global settings and per-tenant - # overrides. - # - # Resolution priority: per-tenant DB > global DB > config file (RubyLLM.configure) - # - # @!attribute [rw] scope_type - # @return [String] Either 'global' or 'tenant' - # @!attribute [rw] scope_id - # @return [String, nil] Tenant ID when scope_type='tenant' - # - # @example Setting global API keys - # config = ApiConfiguration.global - # config.update!( - # openai_api_key: "sk-...", - # anthropic_api_key: "sk-ant-..." - # ) - # - # @example Setting tenant-specific configuration - # tenant_config = ApiConfiguration.for_tenant!("acme_corp") - # tenant_config.update!( - # anthropic_api_key: "sk-ant-tenant-specific...", - # default_model: "claude-sonnet-4-20250514" - # ) - # - # @example Resolving configuration for a tenant - # resolved = ApiConfiguration.resolve(tenant_id: "acme_corp") - # resolved.apply_to_ruby_llm! # Apply to RubyLLM.configuration - # - # @see ResolvedConfig - # @api public - class ApiConfiguration < ::ActiveRecord::Base - self.table_name = "ruby_llm_agents_api_configurations" - - # Valid scope types - SCOPE_TYPES = %w[global tenant].freeze - - # All API key attributes that should be encrypted - API_KEY_ATTRIBUTES = %i[ - openai_api_key - anthropic_api_key - gemini_api_key - deepseek_api_key - mistral_api_key - perplexity_api_key - openrouter_api_key - gpustack_api_key - xai_api_key - ollama_api_key - bedrock_api_key - bedrock_secret_key - bedrock_session_token - vertexai_credentials - ].freeze - - # All endpoint attributes - ENDPOINT_ATTRIBUTES = %i[ - openai_api_base - gemini_api_base - ollama_api_base - gpustack_api_base - xai_api_base - ].freeze - - # All default model attributes - MODEL_ATTRIBUTES = %i[ - default_model - default_embedding_model - default_image_model - default_moderation_model - ].freeze - - # Connection settings attributes - CONNECTION_ATTRIBUTES = %i[ - request_timeout - max_retries - retry_interval - retry_backoff_factor - retry_interval_randomness - http_proxy - ].freeze - - # All configurable attributes (excluding API keys) - NON_KEY_ATTRIBUTES = ( - ENDPOINT_ATTRIBUTES + - MODEL_ATTRIBUTES + - CONNECTION_ATTRIBUTES + - %i[ - openai_organization_id - openai_project_id - bedrock_region - vertexai_project_id - vertexai_location - ] - ).freeze - - # Encrypt all API keys using Rails encrypted attributes - # Requires Rails encryption to be configured (rails credentials:edit) - API_KEY_ATTRIBUTES.each do |key_attr| - encrypts key_attr, deterministic: false - end - - # Validations - validates :scope_type, presence: true, inclusion: { in: SCOPE_TYPES } - validates :scope_id, uniqueness: { scope: :scope_type }, allow_nil: true - validate :validate_scope_consistency - - # Scopes - scope :global_config, -> { where(scope_type: "global", scope_id: nil) } - scope :for_scope, ->(type, id) { where(scope_type: type, scope_id: id) } - scope :tenant_configs, -> { where(scope_type: "tenant") } - - # Provider configuration mappings for display - PROVIDERS = { - openai: { - name: "OpenAI", - key_attr: :openai_api_key, - base_attr: :openai_api_base, - extra_attrs: [:openai_organization_id, :openai_project_id], - capabilities: ["Chat", "Embeddings", "Images", "Moderation"] - }, - anthropic: { - name: "Anthropic", - key_attr: :anthropic_api_key, - capabilities: ["Chat"] - }, - gemini: { - name: "Google Gemini", - key_attr: :gemini_api_key, - base_attr: :gemini_api_base, - capabilities: ["Chat", "Embeddings", "Images"] - }, - deepseek: { - name: "DeepSeek", - key_attr: :deepseek_api_key, - capabilities: ["Chat"] - }, - mistral: { - name: "Mistral", - key_attr: :mistral_api_key, - capabilities: ["Chat", "Embeddings"] - }, - perplexity: { - name: "Perplexity", - key_attr: :perplexity_api_key, - capabilities: ["Chat"] - }, - openrouter: { - name: "OpenRouter", - key_attr: :openrouter_api_key, - capabilities: ["Chat"] - }, - gpustack: { - name: "GPUStack", - key_attr: :gpustack_api_key, - base_attr: :gpustack_api_base, - capabilities: ["Chat"] - }, - xai: { - name: "xAI", - key_attr: :xai_api_key, - base_attr: :xai_api_base, - capabilities: ["Chat"] - }, - ollama: { - name: "Ollama", - key_attr: :ollama_api_key, - base_attr: :ollama_api_base, - capabilities: ["Chat", "Embeddings"] - }, - bedrock: { - name: "AWS Bedrock", - key_attr: :bedrock_api_key, - extra_attrs: [:bedrock_secret_key, :bedrock_session_token, :bedrock_region], - capabilities: ["Chat", "Embeddings"] - }, - vertexai: { - name: "Google Vertex AI", - key_attr: :vertexai_credentials, - extra_attrs: [:vertexai_project_id, :vertexai_location], - capabilities: ["Chat", "Embeddings"] - } - }.freeze - - class << self - # Finds or creates the global configuration - # - # @return [ApiConfiguration] The global configuration record - def global - global_config.first_or_create! - end - - # Finds a tenant-specific configuration - # - # @param tenant_id [String] The tenant identifier - # @return [ApiConfiguration, nil] The tenant configuration or nil - def for_tenant(tenant_id) - return nil if tenant_id.blank? - - for_scope("tenant", tenant_id).first - end - - # Finds or creates a tenant-specific configuration - # - # @param tenant_id [String] The tenant identifier - # @return [ApiConfiguration] The tenant configuration record - def for_tenant!(tenant_id) - raise ArgumentError, "tenant_id cannot be blank" if tenant_id.blank? - - for_scope("tenant", tenant_id).first_or_create!( - scope_type: "tenant", - scope_id: tenant_id - ) - end - - # Resolves the effective configuration for a given tenant - # - # Creates a ResolvedConfig that combines tenant config > global DB > RubyLLM config - # - # @param tenant_id [String, nil] Optional tenant identifier - # @return [ResolvedConfig] The resolved configuration - def resolve(tenant_id: nil) - tenant_config = tenant_id.present? ? for_tenant(tenant_id) : nil - global = global_config.first - - RubyLLM::Agents::ResolvedConfig.new( - tenant_config: tenant_config, - global_config: global, - ruby_llm_config: ruby_llm_current_config - ) - end - - # Attempts to get the current RubyLLM configuration object - # Gets the current RubyLLM configuration object - # - # @return [Object, nil] The RubyLLM config object or nil - def ruby_llm_current_config - return nil unless defined?(::RubyLLM) - return nil unless RubyLLM.respond_to?(:config) - - RubyLLM.config - rescue StandardError - nil - end - - # Checks if the table exists (for graceful degradation) - # - # @return [Boolean] - def table_exists? - connection.table_exists?(table_name) - rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid - false - end - end - - # Checks if a specific attribute has a value set - # - # @param attr_name [Symbol, String] The attribute name - # @return [Boolean] - def has_value?(attr_name) - value = send(attr_name) - value.present? - rescue NoMethodError - false - end - - # Returns a masked version of an API key for display - # - # @param attr_name [Symbol, String] The API key attribute name - # @return [String, nil] Masked key like "sk-ab****wxyz" or nil - def masked_key(attr_name) - value = send(attr_name) - return nil if value.blank? - - mask_string(value) - end - - # Returns the source of this configuration - # - # @return [String] "global" or "tenant:ID" - def source_label - scope_type == "global" ? "Global" : "Tenant: #{scope_id}" - end - - # Converts this configuration to a hash suitable for RubyLLM - # - # @return [Hash] Configuration hash with non-nil values - def to_ruby_llm_config - {}.tap do |config| - # API keys - config[:openai_api_key] = openai_api_key if openai_api_key.present? - config[:anthropic_api_key] = anthropic_api_key if anthropic_api_key.present? - config[:gemini_api_key] = gemini_api_key if gemini_api_key.present? - config[:deepseek_api_key] = deepseek_api_key if deepseek_api_key.present? - config[:mistral_api_key] = mistral_api_key if mistral_api_key.present? - config[:perplexity_api_key] = perplexity_api_key if perplexity_api_key.present? - config[:openrouter_api_key] = openrouter_api_key if openrouter_api_key.present? - config[:gpustack_api_key] = gpustack_api_key if gpustack_api_key.present? - config[:xai_api_key] = xai_api_key if xai_api_key.present? - config[:ollama_api_key] = ollama_api_key if ollama_api_key.present? - - # Bedrock - config[:bedrock_api_key] = bedrock_api_key if bedrock_api_key.present? - config[:bedrock_secret_key] = bedrock_secret_key if bedrock_secret_key.present? - config[:bedrock_session_token] = bedrock_session_token if bedrock_session_token.present? - config[:bedrock_region] = bedrock_region if bedrock_region.present? - - # Vertex AI - config[:vertexai_credentials] = vertexai_credentials if vertexai_credentials.present? - config[:vertexai_project_id] = vertexai_project_id if vertexai_project_id.present? - config[:vertexai_location] = vertexai_location if vertexai_location.present? - - # Endpoints - config[:openai_api_base] = openai_api_base if openai_api_base.present? - config[:gemini_api_base] = gemini_api_base if gemini_api_base.present? - config[:ollama_api_base] = ollama_api_base if ollama_api_base.present? - config[:gpustack_api_base] = gpustack_api_base if gpustack_api_base.present? - config[:xai_api_base] = xai_api_base if xai_api_base.present? - - # OpenAI options - config[:openai_organization_id] = openai_organization_id if openai_organization_id.present? - config[:openai_project_id] = openai_project_id if openai_project_id.present? - - # Default models - config[:default_model] = default_model if default_model.present? - config[:default_embedding_model] = default_embedding_model if default_embedding_model.present? - config[:default_image_model] = default_image_model if default_image_model.present? - config[:default_moderation_model] = default_moderation_model if default_moderation_model.present? - - # Connection settings - config[:request_timeout] = request_timeout if request_timeout.present? - config[:max_retries] = max_retries if max_retries.present? - config[:retry_interval] = retry_interval if retry_interval.present? - config[:retry_backoff_factor] = retry_backoff_factor if retry_backoff_factor.present? - config[:retry_interval_randomness] = retry_interval_randomness if retry_interval_randomness.present? - config[:http_proxy] = http_proxy if http_proxy.present? - end - end - - # Returns provider status information for display - # - # @return [Array] Array of provider status hashes - def provider_statuses - PROVIDERS.map do |key, info| - key_value = send(info[:key_attr]) - { - key: key, - name: info[:name], - configured: key_value.present?, - masked_key: key_value.present? ? mask_string(key_value) : nil, - capabilities: info[:capabilities], - has_base_url: info[:base_attr].present? && send(info[:base_attr]).present? - } - end - end - - private - - # Validates scope consistency - def validate_scope_consistency - if scope_type == "global" && scope_id.present? - errors.add(:scope_id, "must be nil for global scope") - elsif scope_type == "tenant" && scope_id.blank? - errors.add(:scope_id, "must be present for tenant scope") - end - end - - # Masks a string for display (shows first 2 and last 4 chars) - # - # @param value [String] The string to mask - # @return [String] Masked string - def mask_string(value) - return nil if value.blank? - return "****" if value.length <= 8 - - "#{value[0..1]}****#{value[-4..]}" - end - end - end -end diff --git a/app/models/ruby_llm/agents/execution.rb b/app/models/ruby_llm/agents/execution.rb index b16b905..cb6ba83 100644 --- a/app/models/ruby_llm/agents/execution.rb +++ b/app/models/ruby_llm/agents/execution.rb @@ -8,8 +8,6 @@ module Agents # # @!attribute [rw] agent_type # @return [String] Full class name of the agent (e.g., "SearchAgent") - # @!attribute [rw] agent_version - # @return [String] Version string for cache invalidation # @!attribute [rw] model_id # @return [String] LLM model identifier used # @!attribute [rw] temperature @@ -37,7 +35,7 @@ module Agents # @!attribute [rw] parameters # @return [Hash] Sanitized parameters passed to the agent # @!attribute [rw] metadata - # @return [Hash] Custom metadata from execution_metadata hook + # @return [Hash] Custom metadata from metadata hook # @!attribute [rw] error_class # @return [String, nil] Exception class name if failed # @!attribute [rw] error_message @@ -51,7 +49,6 @@ class Execution < ::ActiveRecord::Base include Execution::Metrics include Execution::Scopes include Execution::Analytics - include Execution::Workflow # Status enum # - running: execution in progress @@ -72,20 +69,25 @@ class Execution < ::ActiveRecord::Base has_many :child_executions, class_name: "RubyLLM::Agents::Execution", foreign_key: :parent_execution_id, dependent: :nullify, inverse_of: :parent_execution - # Polymorphic association to tenant model (for llm_tenant DSL) - belongs_to :tenant_record, polymorphic: true, optional: true + # Detail record for large payloads (prompts, responses, tool calls, etc.) + has_one :detail, class_name: "RubyLLM::Agents::ExecutionDetail", + foreign_key: :execution_id, dependent: :destroy + + # Delegations so existing code keeps working transparently + delegate :system_prompt, :user_prompt, :response, :error_message, + :messages_summary, :tool_calls, :attempts, :fallback_chain, + :parameters, :routed_to, :classification_result, + :cached_at, :cache_creation_tokens, + to: :detail, prefix: false, allow_nil: true # Validations validates :agent_type, :model_id, :started_at, presence: true validates :status, inclusion: { in: statuses.keys } - validates :agent_version, presence: true validates :temperature, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 2 }, allow_nil: true validates :input_tokens, :output_tokens, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true validates :input_cost, :output_cost, :total_cost, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true validates :finish_reason, inclusion: { in: FINISH_REASONS }, allow_nil: true - validates :fallback_reason, inclusion: { in: FALLBACK_REASONS }, allow_nil: true - validates :time_to_first_token_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true before_save :calculate_total_tokens, if: -> { input_tokens_changed? || output_tokens_changed? } before_save :calculate_total_cost, if: -> { input_cost_changed? || output_cost_changed? } @@ -205,7 +207,41 @@ def cached? # # @return [Boolean] true if rate limiting occurred def rate_limited? - rate_limited == true + metadata&.dig("rate_limited") == true + end + + # Convenience accessors for niche fields stored in metadata JSON + %w[span_id response_cache_key fallback_reason].each do |field| + define_method(field) { metadata&.dig(field) } + define_method(:"#{field}=") { |val| self.metadata = (metadata || {}).merge(field => val) } + end + + %w[time_to_first_token_ms].each do |field| + define_method(field) { metadata&.dig(field)&.to_i } + define_method(:"#{field}=") { |val| self.metadata = (metadata || {}).merge(field => val) } + end + + def retryable + metadata&.dig("retryable") + end + + def retryable=(val) + self.metadata = (metadata || {}).merge("retryable" => val) + end + + def rate_limited + metadata&.dig("rate_limited") + end + + def rate_limited=(val) + self.metadata = (metadata || {}).merge("rate_limited" => val) + end + + # Convenience method to access tenant_record through the tenant + def tenant_record + return nil unless tenant_id.present? + + Tenant.find_by(tenant_id: tenant_id)&.tenant_record end # Returns whether this execution used streaming diff --git a/app/models/ruby_llm/agents/execution/analytics.rb b/app/models/ruby_llm/agents/execution/analytics.rb index 307d8b8..d9762c2 100644 --- a/app/models/ruby_llm/agents/execution/analytics.rb +++ b/app/models/ruby_llm/agents/execution/analytics.rb @@ -82,58 +82,6 @@ def stats_for(agent_type, period: :today) end # Compares performance between two agent versions - # - # @param agent_type [String] The agent class name - # @param version1 [String] First version to compare (baseline) - # @param version2 [String] Second version to compare - # @param period [Symbol] Time scope for comparison - # @return [Hash] Comparison data with stats for each version and improvement percentages - def compare_versions(agent_type, version1, version2, period: :this_week) - base_scope = by_agent(agent_type).public_send(period) - - v1_stats = stats_for_scope(base_scope.by_version(version1)) - v2_stats = stats_for_scope(base_scope.by_version(version2)) - - { - agent_type: agent_type, - period: period, - version1: { version: version1, **v1_stats }, - version2: { version: version2, **v2_stats }, - improvements: { - cost_change_pct: percent_change(v1_stats[:avg_cost], v2_stats[:avg_cost]), - token_change_pct: percent_change(v1_stats[:avg_tokens], v2_stats[:avg_tokens]), - speed_change_pct: percent_change(v1_stats[:avg_duration_ms], v2_stats[:avg_duration_ms]) - } - } - end - - # Returns daily trend data for a specific agent version - # - # Used for sparkline charts in version comparison. - # - # @param agent_type [String] The agent class name - # @param version [String] The version to analyze - # @param days [Integer] Number of days to analyze - # @return [Array] Daily metrics sorted oldest to newest - def version_trend_data(agent_type, version, days: 14) - scope = by_agent(agent_type).by_version(version) - - (0...days).map do |days_ago| - date = days_ago.days.ago.to_date - day_scope = scope.where(created_at: date.beginning_of_day..date.end_of_day) - count = day_scope.count - - { - date: date, - count: count, - success_rate: calculate_success_rate(day_scope), - avg_cost: count > 0 ? ((day_scope.total_cost_sum || 0) / count).round(6) : 0, - avg_duration_ms: day_scope.avg_duration&.round || 0, - avg_tokens: day_scope.avg_tokens&.round || 0 - } - end.reverse - end - # Analyzes trends over a time period # # @param agent_type [String, nil] Filter to specific agent, or nil for all diff --git a/app/models/ruby_llm/agents/execution/scopes.rb b/app/models/ruby_llm/agents/execution/scopes.rb index eea62ef..eed8358 100644 --- a/app/models/ruby_llm/agents/execution/scopes.rb +++ b/app/models/ruby_llm/agents/execution/scopes.rb @@ -79,17 +79,11 @@ module Scopes # @param agent_type [String] The agent class name # @return [ActiveRecord::Relation] - # @!method by_version(version) - # Filters to a specific agent version - # @param version [String] The version string - # @return [ActiveRecord::Relation] - # @!method by_model(model_id) # Filters to a specific LLM model # @param model_id [String] The model identifier # @return [ActiveRecord::Relation] scope :by_agent, ->(agent_type) { where(agent_type: agent_type.to_s) } - scope :by_version, ->(version) { where(agent_version: version.to_s) } scope :by_model, ->(model_id) { where(model_id: model_id.to_s) } # @!endgroup diff --git a/app/models/ruby_llm/agents/execution/workflow.rb b/app/models/ruby_llm/agents/execution/workflow.rb deleted file mode 100644 index 856df45..0000000 --- a/app/models/ruby_llm/agents/execution/workflow.rb +++ /dev/null @@ -1,170 +0,0 @@ -# frozen_string_literal: true - -module RubyLLM - module Agents - class Execution - # Workflow concern for workflow-related methods and aggregate calculations - # - # Provides instance methods for determining workflow type, calculating - # aggregate statistics across child executions, and retrieving workflow - # step/branch information. - # - # @see RubyLLM::Agents::Execution - # @api public - module Workflow - extend ActiveSupport::Concern - - # Returns whether this is a workflow execution (has workflow_type) - # - # @return [Boolean] true if this is a workflow execution - def workflow? - workflow_type.present? - end - - # Returns whether this is a root workflow execution (top-level) - # - # @return [Boolean] true if this is a workflow with no parent - def root_workflow? - workflow? && root? - end - - # Returns all workflow steps/branches ordered by creation time - # - # @return [ActiveRecord::Relation] Child executions for this workflow - def workflow_steps - child_executions.order(:created_at) - end - - # Returns the count of child workflow steps - # - # @return [Integer] Number of child executions - def workflow_steps_count - child_executions.count - end - - # @!group Aggregate Statistics - - # Returns aggregate stats for all child executions - # - # @return [Hash] Aggregated metrics including cost, tokens, duration - def workflow_aggregate_stats - return @workflow_aggregate_stats if defined?(@workflow_aggregate_stats) - - children = child_executions.to_a - return empty_aggregate_stats if children.empty? - - @workflow_aggregate_stats = { - total_cost: children.sum { |c| c.total_cost || 0 }, - total_tokens: children.sum { |c| c.total_tokens || 0 }, - input_tokens: children.sum { |c| c.input_tokens || 0 }, - output_tokens: children.sum { |c| c.output_tokens || 0 }, - total_duration_ms: children.sum { |c| c.duration_ms || 0 }, - wall_clock_ms: calculate_wall_clock_duration(children), - steps_count: children.size, - successful_count: children.count(&:status_success?), - failed_count: children.count(&:status_error?), - timeout_count: children.count(&:status_timeout?), - running_count: children.count(&:status_running?), - success_rate: calculate_success_rate(children), - models_used: children.map(&:model_id).uniq.compact - } - end - - # Returns aggregate total cost across all child executions - # - # @return [Float] Total cost in USD - def workflow_total_cost - workflow_aggregate_stats[:total_cost] - end - - # Returns aggregate total tokens across all child executions - # - # @return [Integer] Total tokens used - def workflow_total_tokens - workflow_aggregate_stats[:total_tokens] - end - - # Returns the wall-clock duration (from first start to last completion) - # - # @return [Integer, nil] Duration in milliseconds - def workflow_wall_clock_ms - workflow_aggregate_stats[:wall_clock_ms] - end - - # Returns the sum of all step durations (may exceed wall-clock for parallel) - # - # @return [Integer] Sum of all durations in milliseconds - def workflow_sum_duration_ms - workflow_aggregate_stats[:total_duration_ms] - end - - # Returns the overall workflow status based on child executions - # - # @return [Symbol] :success, :error, :timeout, :running, or :pending - def workflow_overall_status - stats = workflow_aggregate_stats - return :pending if stats[:steps_count].zero? - return :running if stats[:running_count] > 0 - return :error if stats[:failed_count] > 0 - return :timeout if stats[:timeout_count] > 0 - - :success - end - - # @!endgroup - - private - - # Returns empty aggregate stats hash - # - # @return [Hash] Empty stats with zero values - def empty_aggregate_stats - { - total_cost: 0, - total_tokens: 0, - input_tokens: 0, - output_tokens: 0, - total_duration_ms: 0, - wall_clock_ms: nil, - steps_count: 0, - successful_count: 0, - failed_count: 0, - timeout_count: 0, - running_count: 0, - success_rate: 0.0, - models_used: [] - } - end - - # Calculates wall-clock duration from child executions - # - # @param children [Array] Child executions - # @return [Integer, nil] Duration in milliseconds - def calculate_wall_clock_duration(children) - started_times = children.map(&:started_at).compact - completed_times = children.map(&:completed_at).compact - - return nil if started_times.empty? || completed_times.empty? - - first_start = started_times.min - last_complete = completed_times.max - - ((last_complete - first_start) * 1000).round - end - - # Calculates success rate from children - # - # @param children [Array] Child executions - # @return [Float] Success rate as percentage - def calculate_success_rate(children) - return 0.0 if children.empty? - - completed = children.reject(&:status_running?) - return 0.0 if completed.empty? - - (completed.count(&:status_success?).to_f / completed.size * 100).round(1) - end - end - end - end -end diff --git a/app/models/ruby_llm/agents/execution_detail.rb b/app/models/ruby_llm/agents/execution_detail.rb new file mode 100644 index 0000000..ad775f1 --- /dev/null +++ b/app/models/ruby_llm/agents/execution_detail.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyLLM + module Agents + # Stores large payload data for an execution (prompts, responses, tool calls, etc.) + # + # Separated from {Execution} to keep the main table lean for analytics queries. + # Only created when there is detail data to store. + # + # @see Execution + # @api public + class ExecutionDetail < ::ActiveRecord::Base + self.table_name = "ruby_llm_agents_execution_details" + + belongs_to :execution, class_name: "RubyLLM::Agents::Execution" + end + end +end diff --git a/app/models/ruby_llm/agents/tenant.rb b/app/models/ruby_llm/agents/tenant.rb index 127f2b1..a2b640e 100644 --- a/app/models/ruby_llm/agents/tenant.rb +++ b/app/models/ruby_llm/agents/tenant.rb @@ -7,7 +7,6 @@ module Agents # Encapsulates all tenant-related functionality: # - Budget limits and enforcement (via Budgetable concern) # - Usage tracking: cost, tokens, executions (via Trackable concern) - # - API configuration per tenant (via Configurable concern) # # @example Creating a tenant # Tenant.create!( @@ -30,7 +29,6 @@ module Agents # # @see Tenant::Budgetable # @see Tenant::Trackable - # @see Tenant::Configurable # @see LLMTenant # @api public class Tenant < ::ActiveRecord::Base @@ -39,7 +37,6 @@ class Tenant < ::ActiveRecord::Base # Include concerns for organized functionality include Tenant::Budgetable include Tenant::Trackable - include Tenant::Configurable include Tenant::Resettable include Tenant::Incrementable diff --git a/app/models/ruby_llm/agents/tenant/configurable.rb b/app/models/ruby_llm/agents/tenant/configurable.rb deleted file mode 100644 index 0d0fdb6..0000000 --- a/app/models/ruby_llm/agents/tenant/configurable.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -require "active_support/concern" - -module RubyLLM - module Agents - class Tenant - # Manages API configuration for a tenant. - # - # Links to the ApiConfiguration model to provide per-tenant API keys - # and settings. Supports inheritance from global configuration when - # tenant-specific settings are not defined. - # - # @example Accessing tenant API keys - # tenant = Tenant.for("acme_corp") - # tenant.api_key_for(:openai) # => "sk-..." - # tenant.has_custom_api_keys? # => true - # - # @example Getting effective configuration - # config = tenant.effective_api_configuration - # config.apply_to_ruby_llm! - # - # @see ApiConfiguration - # @api public - module Configurable - extend ActiveSupport::Concern - - included do - # Link to tenant-specific API configuration - has_one :api_configuration, - -> { where(scope_type: "tenant") }, - class_name: "RubyLLM::Agents::ApiConfiguration", - foreign_key: :scope_id, - primary_key: :tenant_id, - dependent: :destroy - end - - # Get API key for a specific provider - # - # @param provider [Symbol] Provider name (:openai, :anthropic, :gemini, etc.) - # @return [String, nil] The API key or nil if not configured - # - # @example - # tenant.api_key_for(:openai) # => "sk-abc123..." - # tenant.api_key_for(:anthropic) # => "sk-ant-xyz..." - def api_key_for(provider) - attr_name = "#{provider}_api_key" - api_configuration&.send(attr_name) if api_configuration&.respond_to?(attr_name) - end - - # Check if tenant has custom API keys configured - # - # @return [Boolean] true if tenant has an ApiConfiguration record - def has_custom_api_keys? - api_configuration.present? - end - - # Get effective API configuration for this tenant - # - # Returns the resolved configuration that combines tenant-specific - # settings with global defaults. - # - # @return [ResolvedConfig] The resolved configuration - # - # @example - # config = tenant.effective_api_configuration - # config.openai_api_key # Tenant's key or global fallback - def effective_api_configuration - ApiConfiguration.resolve(tenant_id: tenant_id) - end - - # Get or create the API configuration for this tenant - # - # @return [ApiConfiguration] The tenant's API configuration record - def api_configuration! - api_configuration || create_api_configuration!( - scope_type: "tenant", - scope_id: tenant_id - ) - end - - # Configure API settings for this tenant - # - # @yield [config] Block to configure the API settings - # @yieldparam config [ApiConfiguration] The configuration to modify - # @return [ApiConfiguration] The saved configuration - # - # @example - # tenant.configure_api do |config| - # config.openai_api_key = "sk-..." - # config.default_model = "gpt-4o" - # end - def configure_api(&block) - config = api_configuration! - yield(config) if block_given? - config.save! - config - end - - # Check if a specific provider is configured for this tenant - # - # @param provider [Symbol] Provider name - # @return [Boolean] true if the provider has an API key set - def provider_configured?(provider) - api_key_for(provider).present? - end - - # Get all configured providers for this tenant - # - # @return [Array] List of configured provider symbols - def configured_providers - return [] unless api_configuration - - ApiConfiguration::PROVIDERS.keys.select do |provider| - provider_configured?(provider) - end - end - - # Get the default model for this tenant - # - # @return [String, nil] The default model or nil - def default_model - api_configuration&.default_model - end - - # Get the default embedding model for this tenant - # - # @return [String, nil] The default embedding model or nil - def default_embedding_model - api_configuration&.default_embedding_model - end - end - end - end -end diff --git a/app/models/ruby_llm/agents/tenant/incrementable.rb b/app/models/ruby_llm/agents/tenant/incrementable.rb index e1cff77..e9f4f22 100644 --- a/app/models/ruby_llm/agents/tenant/incrementable.rb +++ b/app/models/ruby_llm/agents/tenant/incrementable.rb @@ -59,9 +59,6 @@ def record_execution!(cost:, tokens:, error: false) def check_soft_cap_alerts! return unless soft_enforcement? - config = RubyLLM::Agents.configuration - return unless config.alerts_enabled? - check_cost_alerts! check_token_alerts! check_execution_alerts! diff --git a/app/models/ruby_llm/agents/tenant_budget.rb b/app/models/ruby_llm/agents/tenant_budget.rb index 84b1a37..7f16992 100644 --- a/app/models/ruby_llm/agents/tenant_budget.rb +++ b/app/models/ruby_llm/agents/tenant_budget.rb @@ -8,15 +8,18 @@ module Agents # All functionality has been moved to the Tenant model with organized concerns. # # @example Migration path - # # Old usage (still works) + # # Old usage (still works but emits deprecation warning) # TenantBudget.for_tenant("acme_corp") - # TenantBudget.create!(tenant_id: "acme", daily_limit: 100) # # # New usage (preferred) # Tenant.for("acme_corp") - # Tenant.create!(tenant_id: "acme", daily_limit: 100) # # @see Tenant TenantBudget = Tenant + + ActiveSupport.deprecator.warn( + "RubyLLM::Agents::TenantBudget is deprecated. Use RubyLLM::Agents::Tenant instead. " \ + "This alias will be removed in the next major version." + ) end end diff --git a/app/services/ruby_llm/agents/agent_registry.rb b/app/services/ruby_llm/agents/agent_registry.rb index 712f0f1..d5e0ded 100644 --- a/app/services/ruby_llm/agents/agent_registry.rb +++ b/app/services/ruby_llm/agents/agent_registry.rb @@ -21,12 +21,6 @@ module Agents # # @api public class AgentRegistry - # Base workflow classes to exclude from listings - # These are abstract parent classes, not concrete workflows - BASE_WORKFLOW_CLASSES = [ - "RubyLLM::Agents::Workflow" - ].freeze - class << self # Returns all unique agent type names # @@ -66,22 +60,17 @@ def all_with_details # # @return [Array] Agent class names def file_system_agents - # Ensure all agent and workflow classes are loaded + # Ensure all agent classes are loaded eager_load_agents! # Find all descendants of all base classes agents = RubyLLM::Agents::Base.descendants.map(&:name).compact - workflows = RubyLLM::Agents::Workflow.descendants.map(&:name).compact embedders = RubyLLM::Agents::Embedder.descendants.map(&:name).compact - moderators = RubyLLM::Agents::Moderator.descendants.map(&:name).compact speakers = RubyLLM::Agents::Speaker.descendants.map(&:name).compact transcribers = RubyLLM::Agents::Transcriber.descendants.map(&:name).compact image_generators = RubyLLM::Agents::ImageGenerator.descendants.map(&:name).compact - all_agents = (agents + workflows + embedders + moderators + speakers + transcribers + image_generators).uniq - - # Filter out base workflow classes - all_agents.reject { |name| BASE_WORKFLOW_CLASSES.include?(name) } + (agents + embedders + speakers + transcribers + image_generators).uniq rescue StandardError => e Rails.logger.error("[RubyLLM::Agents] Error loading agents from file system: #{e.message}") [] @@ -97,7 +86,7 @@ def execution_agents [] end - # Eager loads all agent and workflow files to register descendants + # Eager loads all agent files to register descendants # # Uses the configured autoload paths from RubyLLM::Agents.configuration # to ensure agents are discovered in the correct directories. @@ -116,28 +105,6 @@ def eager_load_agents! end end - # Returns only regular agents (non-workflows) - # - # @return [Array] Agent info hashes for non-workflow agents - def agents_only - all_with_details.reject { |a| a[:is_workflow] } - end - - # Returns only workflows - # - # @return [Array] Agent info hashes for workflows only - def workflows_only - all_with_details.select { |a| a[:is_workflow] } - end - - # Returns workflows filtered by type - # - # @param type [String, Symbol] The workflow type (pipeline, parallel, router) - # @return [Array] Filtered workflow info hashes - def workflows_by_type(type) - workflows_only.select { |w| w[:workflow_type] == type.to_s } - end - # Builds detailed info hash for an agent # # @param agent_type [String] The agent class name @@ -146,27 +113,17 @@ def build_agent_info(agent_type) agent_class = find(agent_type) stats = fetch_stats(agent_type) - # Detect the agent type (agent, workflow, embedder, moderator, speaker, transcriber) + # Detect the agent type (agent, embedder, speaker, transcriber, image_generator) detected_type = detect_agent_type(agent_class) - # Check if this is a workflow class vs a regular agent - is_workflow = detected_type == "workflow" - - # Determine specific workflow type and children - workflow_type = is_workflow ? detect_workflow_type(agent_class) : nil - workflow_children = is_workflow ? extract_workflow_children(agent_class) : [] - { name: agent_type, class: agent_class, active: agent_class.present?, agent_type: detected_type, - is_workflow: is_workflow, - workflow_type: workflow_type, - workflow_children: workflow_children, version: safe_call(agent_class, :version) || "N/A", description: safe_call(agent_class, :description), - model: safe_call(agent_class, :model) || (is_workflow ? "workflow" : "N/A"), + model: safe_call(agent_class, :model) || "N/A", temperature: safe_call(agent_class, :temperature), timeout: safe_call(agent_class, :timeout), cache_enabled: safe_call(agent_class, :cache_enabled?) || false, @@ -216,22 +173,10 @@ def last_execution_time(agent_type) nil end - # Detects the specific workflow type from class hierarchy - # - # @param agent_class [Class, nil] The agent class - # @return [String, nil] "workflow" for DSL workflows, or nil - def detect_workflow_type(agent_class) - return nil unless agent_class - - if agent_class.respond_to?(:step_configs) && agent_class.step_configs.any? - "workflow" - end - end - # Detects the agent type from class hierarchy # # @param agent_class [Class, nil] The agent class - # @return [String] "agent", "workflow", "embedder", "moderator", "speaker", "transcriber", or "image_generator" + # @return [String] "agent", "embedder", "speaker", "transcriber", or "image_generator" def detect_agent_type(agent_class) return "agent" unless agent_class @@ -239,67 +184,16 @@ def detect_agent_type(agent_class) if ancestors.include?("RubyLLM::Agents::Embedder") "embedder" - elsif ancestors.include?("RubyLLM::Agents::Moderator") - "moderator" elsif ancestors.include?("RubyLLM::Agents::Speaker") "speaker" elsif ancestors.include?("RubyLLM::Agents::Transcriber") "transcriber" elsif ancestors.include?("RubyLLM::Agents::ImageGenerator") "image_generator" - elsif ancestors.include?("RubyLLM::Agents::Workflow") - "workflow" else "agent" end end - - # Extracts child agents from workflow DSL configuration - # - # @param agent_class [Class, nil] The workflow class - # @return [Array] Array of child info hashes with :name, :agent, :type, :optional keys - def extract_workflow_children(agent_class) - return [] unless agent_class - - children = [] - - if agent_class.respond_to?(:steps) && agent_class.steps.any? - # Pipeline workflow - extract steps - agent_class.steps.each do |name, config| - children << { - name: name, - agent: config[:agent]&.name, - type: "step", - optional: config[:continue_on_error] || false - } - end - elsif agent_class.respond_to?(:branches) && agent_class.branches.any? - # Parallel workflow - extract branches - agent_class.branches.each do |name, config| - children << { - name: name, - agent: config[:agent]&.name, - type: "branch", - optional: config[:optional] || false - } - end - elsif agent_class.respond_to?(:routes) && agent_class.routes.any? - # Router workflow - extract routes - agent_class.routes.each do |name, config| - children << { - name: name, - agent: config[:agent]&.name, - type: "route", - description: config[:description] - } - end - end - - children - rescue StandardError => e - Rails.logger.error("[RubyLLM::Agents] Error extracting workflow children: #{e.message}") - [] - end end end end diff --git a/app/views/layouts/ruby_llm/agents/application.html.erb b/app/views/layouts/ruby_llm/agents/application.html.erb index 332792b..195b728 100644 --- a/app/views/layouts/ruby_llm/agents/application.html.erb +++ b/app/views/layouts/ruby_llm/agents/application.html.erb @@ -37,26 +37,26 @@ credits: { enabled: false }, chart: { backgroundColor: 'transparent', - style: { fontFamily: 'inherit' } + style: { fontFamily: 'ui-monospace, monospace' } }, title: { text: null }, xAxis: { - labels: { style: { color: '#9CA3AF' } }, - lineColor: 'rgba(156, 163, 175, 0.2)', - tickColor: 'rgba(156, 163, 175, 0.2)' + labels: { style: { color: '#6B7280' } }, + lineColor: 'rgba(107, 114, 128, 0.1)', + tickColor: 'rgba(107, 114, 128, 0.1)' }, yAxis: { - labels: { style: { color: '#9CA3AF' } }, - gridLineColor: 'rgba(156, 163, 175, 0.2)' + labels: { style: { color: '#6B7280' } }, + gridLineColor: 'rgba(107, 114, 128, 0.1)' }, legend: { - itemStyle: { color: '#9CA3AF' }, - itemHoverStyle: { color: '#D1D5DB' } + itemStyle: { color: '#6B7280' }, + itemHoverStyle: { color: '#9CA3AF' } }, tooltip: { - backgroundColor: 'rgba(17, 24, 39, 0.9)', - borderColor: 'rgba(75, 85, 99, 0.5)', - style: { color: '#F3F4F6' } + backgroundColor: 'rgba(0, 0, 0, 0.85)', + borderColor: 'transparent', + style: { color: '#E5E7EB', fontFamily: 'ui-monospace, monospace' } } }); @@ -71,12 +71,9 @@ - + - + -
-
-
-
- <%= link_to ruby_llm_agents.root_path, class: "flex items-center space-x-2" do %> - 🤖 - - - RubyLLM Agents - - <% end %> +
+
+ + <%= link_to ruby_llm_agents.root_path, class: "font-mono text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors" do %> + ruby_llm::agents + <% end %> + + + - - <% - nav_items = [ - { path: ruby_llm_agents.root_path, label: "Dashboard", icon: '' }, - { path: ruby_llm_agents.agents_path, label: "Agents", icon: '' }, - { path: ruby_llm_agents.workflows_path, label: "Workflows", icon: '' }, - { path: ruby_llm_agents.executions_path, label: "Executions", icon: '' }, - { path: ruby_llm_agents.tenants_path, label: "Tenants", icon: '' } - ] - %> - -
+ +
+ <%= link_to ruby_llm_agents.system_config_path, class: "hidden md:flex p-1.5 text-gray-400 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 rounded transition-colors", title: "Config" do %> + + + + + <% end %> -
+ - - -
- - - -
+ Open main menu + + + +
- +
-
-
+
<%= yield %>
-