diff --git a/AGENTS.md b/AGENTS.md index 7ee842836..8673fac57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ Creates (the view directory drops the `_agent` suffix, the agent file keeps it): - `app/views/agents/agent_name/action1.md.erb` - `app/views/agents/agent_name/action2.md.erb` - `test/agents/agent_name_agent_test.rb` and - `test/docs/previews/agent_name_agent_preview.rb` + `test/agents/previews/agent_name_agent_preview.rb` ### Adding a Tool to an Agent diff --git a/CHANGELOG.md b/CHANGELOG.md index e5de61652..b42ccb052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Discover and call generated agent previews** (`activeagent`). TestUnit + writes previews under `test/agents/previews`, with current URLs and query + parameters passed to the agent. Discovery checks for newly added files, + and the default image interceptor accepts generation objects. + +- **Restore Rails browser previews** (`activeagent`). `/rails/agents` lists + previews and renders their prompt requests without executing a provider. + Namespaced actions and Markdown downloads work, unknown actions return + 404, and all endpoints enforce Rails' local-request restriction. Preview + directories participate in development reloading, including configured + paths and the legacy `test/docs/previews` directory. The dashboard gem is + not needed; the routes remain disabled outside development by default. + - **Generate runnable agent tests** (`activeagent`). TestUnit agent tests inherit from `ActiveSupport::TestCase` instead of the nonexistent `ActiveAgent::TestCase`. Plain and namespaced agents, including agents diff --git a/docs/framework/rails.md b/docs/framework/rails.md index c73d55ae7..50548e3f9 100644 --- a/docs/framework/rails.md +++ b/docs/framework/rails.md @@ -77,7 +77,7 @@ app/agents/support_agent.rb app/views/agents/support/instructions.md app/views/agents/support/respond.md.erb test/agents/support_agent_test.rb -test/docs/previews/support_agent_preview.rb +test/agents/previews/support_agent_preview.rb ``` Multiple actions: @@ -212,3 +212,37 @@ end - **[Configuration](/framework/configuration)** - Environment-specific settings and YAML configuration - **[Instrumentation](/framework/instrumentation)** - Rails.logger integration and event monitoring + +## Browser previews + +In development, open `/rails/agents` to list your agent previews. Choose an +action to inspect the request as escaped Markdown, or download it as a +`.md` file. A preview builds the request without calling the provider. It +uses the agent's normal provider configuration. + +The generator writes `ActiveAgent::Preview` subclasses under +`test/agents/previews`. Existing previews under `test/docs/previews` are +also discovered. Add another directory before the application initializes: + +```ruby +# config/application.rb +config.active_agent.preview_paths << Rails.root.join("lib/agent_previews").to_s +``` + +Generated preview methods pass their query parameters through `with(params)` +to the agent. An action that needs parameters can be opened with, for +example, `/rails/agents/support_agent/respond?message=Hello`. +Namespaced previews use `/rails/agents/admin/user_agent/create`. +Preview files reload with the application in development. + +Set `config.active_agent.show_previews = false` to omit the routes. +Previews default to disabled outside development. When enabled, the +controller uses the same local-request restriction as Rails' own diagnostic +pages. Setting `config.consider_all_requests_local = true` bypasses that +restriction, so remote requests can access previews when +`config.active_agent.show_previews = true`. Preview content can include +agent instructions and interpolated parameters; keep +`config.consider_all_requests_local = false` in any remotely accessible +environment where previews are enabled. + +The `actionagent` dashboard gem is not required. diff --git a/lib/active_agent/concerns/preview.rb b/lib/active_agent/concerns/preview.rb index cedb6c801..a3bddc230 100644 --- a/lib/active_agent/concerns/preview.rb +++ b/lib/active_agent/concerns/preview.rb @@ -68,7 +68,7 @@ def initialize(params = {}) class << self # Returns all agent preview classes. def all - load_previews if descendants.empty? + load_previews descendants.sort_by { |agent| agent.name.titleize } end @@ -111,7 +111,7 @@ def preview_name def load_previews preview_paths.each do |preview_path| - Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require file } + Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require_dependency file } end end diff --git a/lib/active_agent/inline_preview_interceptor.rb b/lib/active_agent/inline_preview_interceptor.rb index dad912bd7..2f3539d5b 100644 --- a/lib/active_agent/inline_preview_interceptor.rb +++ b/lib/active_agent/inline_preview_interceptor.rb @@ -28,6 +28,7 @@ def initialize(context) # :nodoc: end def transform! # :nodoc: + return context unless context.respond_to?(:html_part) return context if html_part.blank? html_part.body = html_part.decoded.gsub(PATTERN) do |match| diff --git a/lib/active_agent/preview/templates/layouts/active_agent/preview.html.erb b/lib/active_agent/preview/templates/layouts/active_agent/preview.html.erb new file mode 100644 index 000000000..bfdaa22ce --- /dev/null +++ b/lib/active_agent/preview/templates/layouts/active_agent/preview.html.erb @@ -0,0 +1,17 @@ + + +
+ + +No previews found. Generate an agent or add an ActiveAgent::Preview subclass under test/agents/previews.
+<% end %> +<% @previews.each do |preview| %> +<%= link_to "Download Markdown", "#{download_rails_agent_preview_path("#{@preview.preview_name}/#{@prompt}")}?#{request.query_parameters.to_query}" %>
+<%= @content %>diff --git a/lib/active_agent/railtie.rb b/lib/active_agent/railtie.rb index 5de53fe00..fef752e63 100644 --- a/lib/active_agent/railtie.rb +++ b/lib/active_agent/railtie.rb @@ -6,6 +6,8 @@ require "active_agent/railtie/schema_generator_extension" require "abstract_controller/railties/routes_helpers" +Rails.autoload :AgentsController, "rails/agents_controller" + module ActiveAgent class Railtie < Rails::Railtie # :nodoc: config.active_agent = ActiveSupport::OrderedOptions.new @@ -101,8 +103,12 @@ class Railtie < Rails::Railtie # :nodoc: end initializer "active_agent.set_autoload_paths", before: :set_autoload_paths do |app| - # options = app.config.active_agent - # app.config.paths["test/agents/previews"].concat(options.preview_paths) + options = app.config.active_agent + options.preview_paths |= [ "#{app.root}/test/agents/previews", "#{app.root}/test/docs/previews" ] + # This runs before set_configs assigns show_previews its development default. + if options.show_previews.nil? ? Rails.env.development? : options.show_previews + app.config.autoload_paths.concat(options.preview_paths) + end end initializer "active_agent.compile_config_methods" do @@ -132,9 +138,9 @@ class Railtie < Rails::Railtie # :nodoc: if options.show_previews app.routes.prepend do - get "/rails/agents" => "rails/agents#index", :internal => true - get "/rails/agents/download/*path" => "rails/agents#download", :internal => true - get "/rails/agents/*path" => "rails/agents#preview", :internal => true + get "/rails/agents" => "rails/agents#index", :internal => true, :as => :rails_agent_previews + get "/rails/agents/download/*path" => "rails/agents#download", :internal => true, :as => :download_rails_agent_preview, :format => false + get "/rails/agents/*path" => "rails/agents#preview", :internal => true, :as => :rails_agent_preview, :format => false end end end diff --git a/lib/generators/test_unit/agent_generator.rb b/lib/generators/test_unit/agent_generator.rb index 2fbde3915..f33526df3 100644 --- a/lib/generators/test_unit/agent_generator.rb +++ b/lib/generators/test_unit/agent_generator.rb @@ -17,7 +17,7 @@ def create_test_files end def create_preview_files - template "preview.rb", File.join("test/docs/previews", class_path, "#{file_name}_agent_preview.rb") + template "preview.rb", File.join("test/agents/previews", class_path, "#{file_name}_agent_preview.rb") end private diff --git a/lib/generators/test_unit/templates/preview.rb.tt b/lib/generators/test_unit/templates/preview.rb.tt index c46df4b8e..2e08c8bf4 100644 --- a/lib/generators/test_unit/templates/preview.rb.tt +++ b/lib/generators/test_unit/templates/preview.rb.tt @@ -1,13 +1,13 @@ <% module_namespacing do -%> -# Preview all agent views/prompts templates at http://localhost:3000/active_agent/agents/<%= file_path %>_agent +# Preview all agent views/prompts templates at http://localhost:3000/rails/agents/<%= file_path %>_agent class <%= class_name %>AgentPreview < ActiveAgent::Preview <% actions.each_with_index do |action, index| -%> <% if index != 0 -%> <% end -%> - # Preview this email at http://localhost:3000/active_agent/agents/<%= file_path %>_agent/<%= action %> + # Preview this prompt at http://localhost:3000/rails/agents/<%= file_path %>_agent/<%= action %> def <%= action %> - <%= class_name %>Agent.<%= action %> + <%= class_name %>Agent.with(params).<%= action %> end <% end -%> end diff --git a/lib/rails/agents_controller.rb b/lib/rails/agents_controller.rb new file mode 100644 index 000000000..92ecbc0b8 --- /dev/null +++ b/lib/rails/agents_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails/application_controller" + +class Rails::AgentsController < Rails::ApplicationController # :nodoc: + prepend_view_path File.expand_path("../active_agent/preview/templates", __dir__) + layout "active_agent/preview" + before_action :require_local! + + def index + @previews = ActiveAgent::Preview.all + end + + def preview + if found = ActiveAgent::Preview.find(params[:path]) + @previews = [ found ] + render :index + elsif load_preview + render :preview + end + end + + def download + return unless load_preview + + send_data @content, type: "text/markdown", disposition: "attachment", + filename: "#{@preview.preview_name.tr('/', '-')}-#{@prompt}.md" + end + + private + + def load_preview + name, _, @prompt = params[:path].to_s.rpartition("/") + @preview = ActiveAgent::Preview.find(name) + unless @preview&.prompt_exists?(@prompt) + head :not_found + return false + end + + @content = @preview.call(@prompt, request.query_parameters).prompt_preview + true + end +end diff --git a/test/generators/test_unit/agent_generator_test.rb b/test/generators/test_unit/agent_generator_test.rb index 01c6d5dc1..dba698632 100644 --- a/test/generators/test_unit/agent_generator_test.rb +++ b/test/generators/test_unit/agent_generator_test.rb @@ -19,10 +19,13 @@ class TestUnit::Generators::AgentGeneratorTest < Rails::Generators::TestCase test "generates preview files" do run_generator [ "user", "create", "update" ] - assert_file "test/docs/previews/user_agent_preview.rb" do |content| + assert_file "test/agents/previews/user_agent_preview.rb" do |content| assert_match(/class UserAgentPreview < ActiveAgent::Preview/, content) assert_match(/def create/, content) assert_match(/def update/, content) + assert_includes content, "/rails/agents/user_agent/create" + assert_not_includes content, "Preview this email" + assert_includes content, "UserAgent.with(params).create" end end @@ -33,7 +36,7 @@ class TestUnit::Generators::AgentGeneratorTest < Rails::Generators::TestCase assert_match(/class Admin::UserAgentTest < ActiveSupport::TestCase/, content) end - assert_file "test/docs/previews/admin/user_agent_preview.rb" do |content| + assert_file "test/agents/previews/admin/user_agent_preview.rb" do |content| assert_match(/class Admin::UserAgentPreview < ActiveAgent::Preview/, content) end end @@ -59,7 +62,7 @@ class TestUnit::Generators::AgentGeneratorTest < Rails::Generators::TestCase assert_no_match(/^ test "/, content) # No actual test methods (only commented ones) end - assert_file "test/docs/previews/user_agent_preview.rb" do |content| + assert_file "test/agents/previews/user_agent_preview.rb" do |content| assert_match(/class UserAgentPreview < ActiveAgent::Preview/, content) assert_no_match(/def [a-z]/, content) # No action methods end diff --git a/test/preview_integration_test.rb b/test/preview_integration_test.rb new file mode 100644 index 000000000..d1885cca2 --- /dev/null +++ b/test/preview_integration_test.rb @@ -0,0 +1,119 @@ +require "test_helper" +require "generators/active_agent/agent/agent_generator" +require "generators/test_unit/agent_generator" +require "generators/erb/agent_generator" +require "open3" +require "tmpdir" + +class RailsAgentPreviewIntegrationTest < ActiveSupport::TestCase + test "generated previews can be listed rendered downloaded and reloaded" do + assert_preview_host(enabled: true) + end + + test "disabled previews have no routes" do + assert_preview_host(enabled: false) + end + + test "production does not expose previews by default" do + assert_preview_host(enabled: nil, environment: "production") + end + + private + + def assert_preview_host(enabled:, environment: "development") + Dir.mktmpdir("agent-preview-host") do |root| + FileUtils.mkdir_p("#{root}/app/agents") + FileUtils.mkdir_p("#{root}/config") + File.write("#{root}/app/agents/application_agent.rb", <<~RUBY) + class ApplicationAgent < ActiveAgent::Base + generate_with :mock + end + RUBY + %w[preview_sample admin/preview_sample].each do |name| + ActiveAgent::Generators::AgentGenerator.start( + [ name, "greet", "--test-framework=test_unit", "--quiet" ], destination_root: root + ) + end + FileUtils.mkdir_p("#{root}/test/docs/previews") + File.write("#{root}/test/docs/previews/legacy_preview.rb", "class LegacyPreview < ActiveAgent::Preview; end") + FileUtils.mkdir_p("#{root}/custom_previews") + File.write("#{root}/custom_previews/custom_preview.rb", "class CustomPreview < ActiveAgent::Preview; end") + File.write("#{root}/config/routes.rb", "Rails.application.routes.draw {}") + File.write("#{root}/check.rb", host_script(enabled:, environment:)) + output, status = Open3.capture2e(RbConfig.ruby, "check.rb", chdir: root) + assert status.success?, output + assert_includes output, "preview host verified" + end + end + + def host_script(enabled:, environment:) + <<~RUBY + ENV["RAILS_ENV"] = #{environment.inspect} + require "rails" + require "active_model/railtie" + require "active_job/railtie" + require "action_controller/railtie" + require "active_agent" + require "webmock" + WebMock.disable_net_connect! + class PreviewHost < Rails::Application + config.root = __dir__ + config.eager_load = false + config.cache_classes = false + config.secret_key_base = "synthetic-test-secret" + config.logger = Logger.new(File::NULL) + config.hosts.clear + config.consider_all_requests_local = false + config.action_dispatch.show_exceptions = :none + config.active_agent.show_previews = #{enabled.inspect} + config.active_agent.preview_paths << File.join(__dir__, "custom_previews") + end + PreviewHost.initialize! + require "active_agent/providers/mock_provider" + ActiveAgent::Providers::MockProvider.prepend(Module.new do + def api_prompt_execute(*) + raise "previews must not execute a provider request" + end + end) + require "action_dispatch/testing/integration" + session = ActionDispatch::Integration::Session.new(Rails.application) + session.remote_addr = "127.0.0.1" + if #{!!enabled} + session.get "/rails/agents" + raise "index failed: \#{session.response.status}" unless session.response.status == 200 + %w[preview_sample_agent admin/preview_sample_agent legacy custom].each do |name| + raise "missing \#{name}" unless session.response.body.include?(name) + end + session.get "/rails/agents/admin/preview_sample_agent" + raise "namespaced list failed" unless session.response.status == 200 + session.get "/rails/agents/admin/preview_sample_agent/greet", params: { message: "", host: "example.invalid", script_name: "//example.invalid" } + raise "preview failed: \#{session.response.status}" unless session.response.status == 200 + raise "unsafe download link" unless session.response.body.include?('href="/rails/agents/download/admin/preview_sample_agent/greet?') + raise "unescaped content" unless session.response.body.include?("<script>synthetic</script>") + session.get "/rails/agents/download/preview_sample_agent/greet", params: { message: "synthetic download" } + raise "download failed" unless session.response.status == 200 && session.response.body.include?("synthetic download") + raise "wrong download type" unless session.response.media_type == "text/markdown" + raise "not an attachment" unless session.response.headers["Content-Disposition"].include?("attachment") + %w[missing/greet preview_sample_agent/missing preview_sample_agent/object_id].each do |path| + session.get "/rails/agents/\#{path}" + raise "unknown path accepted" unless session.response.status == 404 + end + file = File.join(__dir__, "test/agents/previews/preview_sample_agent_preview.rb") + File.write(file, File.read(file).sub("def greet", "def changed")) + Rails.application.reloader.reload! + session.get "/rails/agents/preview_sample_agent" + raise "reload failed" unless session.response.status == 200 && session.response.body.include?("changed") + session.get "/rails/agents/preview_sample_agent/changed" + raise "reloaded prompt failed" unless session.response.status == 200 + session.remote_addr = "203.0.113.7" + [ "/rails/agents", "/rails/agents/preview_sample_agent/changed", "/rails/agents/download/preview_sample_agent/changed" ].each do |path| + session.get path + raise "remote preview accepted" unless session.response.status == 403 + end + else + raise "preview route exists" if Rails.application.routes.routes.any? { |route| route.defaults[:controller] == "rails/agents" } + end + puts "preview host verified" + RUBY + end +end diff --git a/test/preview_test.rb b/test/preview_test.rb new file mode 100644 index 000000000..b7d991268 --- /dev/null +++ b/test/preview_test.rb @@ -0,0 +1,52 @@ +require "test_helper" +require "tmpdir" + +class AgentPreviewTest < ActiveSupport::TestCase + class SyntheticAgent < ActiveAgent::Base + generate_with :mock + + def greet + prompt(message: "Synthetic preview") + end + end + + class SyntheticPreview < ActiveAgent::Preview + def greet + SyntheticAgent.greet + end + end + + test "the default preview interceptor accepts a generation" do + generation = SyntheticPreview.call(:greet) + assert_kind_of ActiveAgent::Generation, generation + assert_includes generation.prompt_preview, "Synthetic preview" + end + + test "mail-shaped previews still have inline images rewritten" do + part = Struct.new(:body) do + def decoded = body + end.new('