├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── activeagent.gemspec ├── bin ├── rubocop └── test ├── fixtures └── vcr_cassettes │ ├── application_agent_embeddings.yml │ ├── application_agent_message_embedding.yml │ ├── ollama_text_prompt_response.yml │ ├── open_router_text_prompt_response.yml │ ├── openai_text_prompt_response.yml │ ├── support_agent_text_prompt_tool_call_response.yml │ └── support_agent_tool_call.yml ├── lib ├── active_agent.rb ├── active_agent │ ├── README.md │ ├── action_prompt.rb │ ├── action_prompt │ │ ├── README.md │ │ ├── action.rb │ │ ├── message.rb │ │ └── prompt.rb │ ├── base.rb │ ├── callbacks.rb │ ├── collector.rb │ ├── deprecator.rb │ ├── generation.rb │ ├── generation_job.rb │ ├── generation_methods.rb │ ├── generation_provider.rb │ ├── generation_provider │ │ ├── README.md │ │ ├── anthropic_provider.rb │ │ ├── base.rb │ │ ├── ollama_provider.rb │ │ ├── open_ai_provider.rb │ │ ├── open_router_provider.rb │ │ └── response.rb │ ├── inline_preview_interceptor.rb │ ├── log_subscriber.rb │ ├── parameterized.rb │ ├── preview.rb │ ├── prompt_helper.rb │ ├── queued_generation.rb │ ├── railtie.rb │ ├── rescuable.rb │ ├── service.rb │ ├── test_case.rb │ └── version.rb ├── activeagent.rb ├── generators │ └── active_agent │ │ ├── USAGE │ │ ├── agent_generator.rb │ │ ├── install_generator.rb │ │ └── templates │ │ ├── action.html.erb.tt │ │ ├── action.json.jbuilder.tt │ │ ├── active_agent.yml │ │ ├── agent.html.erb │ │ ├── agent.rb.tt │ │ ├── agent.text.erb │ │ ├── agent_spec.rb.tt │ │ ├── agent_test.rb.tt │ │ └── application_agent.rb.tt └── tasks │ └── activeagent_tasks.rake └── test ├── application_agent_test.rb ├── dummy ├── Gemfile ├── Gemfile.lock ├── Rakefile ├── app │ ├── agents │ │ ├── application_agent.rb │ │ ├── ollama_agent.rb │ │ ├── open_ai_agent.rb │ │ ├── open_router_agent.rb │ │ └── support_agent.rb │ ├── assets │ │ ├── images │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── controllers │ │ ├── application_controller.rb │ │ └── concerns │ │ │ └── .keep │ ├── helpers │ │ └── application_helper.rb │ ├── jobs │ │ └── application_job.rb │ ├── mailers │ │ └── application_mailer.rb │ ├── models │ │ ├── application_record.rb │ │ └── concerns │ │ │ └── .keep │ ├── services │ │ └── cat_image_service.rb │ └── views │ │ ├── layouts │ │ ├── agent.text.erb │ │ ├── application.html.erb │ │ ├── mailer.html.erb │ │ └── mailer.text.erb │ │ ├── pwa │ │ ├── manifest.json.erb │ │ └── service-worker.js │ │ └── support_agent │ │ ├── get_cat_image.json.erb │ │ └── get_cat_image.text.erb ├── bin │ ├── dev │ ├── rails │ ├── rake │ └── setup ├── config.ru ├── config │ ├── active_agent.yml │ ├── application.rb │ ├── boot.rb │ ├── cable.yml │ ├── credentials.yml.enc │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── content_security_policy.rb │ │ ├── filter_parameter_logging.rb │ │ └── inflections.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── routes.rb │ └── storage.yml ├── log │ └── .keep └── public │ ├── 400.html │ ├── 404.html │ ├── 406-unsupported-browser.html │ ├── 422.html │ ├── 500.html │ ├── icon.png │ └── icon.svg ├── ollama_agent_test.rb ├── open_ai_agent_test.rb ├── open_router_agent_test.rb ├── prompt_test.rb ├── support_agent_test.rb └── test_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: github-actions 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Ruby 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: ruby-3.3.0 19 | bundler-cache: true 20 | 21 | - name: Lint code for consistent style 22 | run: bin/rubocop -f github 23 | 24 | test: 25 | runs-on: ubuntu-latest 26 | # services: 27 | # redis: 28 | # image: redis 29 | # ports: 30 | # - 6379:6379 31 | # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 32 | steps: 33 | - name: Install packages 34 | run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config 35 | 36 | - name: Checkout code 37 | uses: actions/checkout@v4 38 | 39 | - name: Set up Ruby 40 | uses: ruby/setup-ruby@v1 41 | with: 42 | ruby-version: ruby-3.3.0 43 | bundler-cache: true 44 | 45 | - name: Run tests 46 | env: 47 | RAILS_ENV: test 48 | RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} 49 | # REDIS_URL: redis://localhost:6379/0 50 | run: bin/test 51 | 52 | - name: Keep screenshots from failed system tests 53 | uses: actions/upload-artifact@v4 54 | if: failure() 55 | with: 56 | name: screenshots 57 | path: ${{ github.workspace }}/tmp/screenshots 58 | if-no-files-found: ignore 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /doc/ 3 | /log/*.log 4 | /pkg/ 5 | /tmp/ 6 | /test/dummy/db/*.sqlite3 7 | /test/dummy/db/*.sqlite3-* 8 | /test/dummy/log/*.log 9 | /test/dummy/storage/ 10 | /test/dummy/tmp/ 11 | **/.DS_Store 12 | test/dummy/config/master.key 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Omakase Ruby styling for Rails 2 | inherit_gem: { rubocop-rails-omakase: rubocop.yml } 3 | 4 | # Overwrite or add rules to create your own house style 5 | # 6 | # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` 7 | # Layout/SpaceInsideArrayLiteralBrackets: 8 | # Enabled: false 9 | 10 | AllCops: 11 | TargetRubyVersion: 3.3 12 | NewCops: enable 13 | SuggestExtensions: false 14 | 15 | Style/StringLiterals: 16 | EnforcedStyle: double_quotes 17 | 18 | Style/Documentation: 19 | Enabled: false 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.3.2] - 2025-04-15 9 | 10 | ### Added 11 | - CI configuration for stable GitHub releases moving forward. 12 | - Test coverage for core features: ActionPrompt rendering, tool calls, and embeddings. 13 | - Enhance streaming to support tool calls during stream. Previously, streaming mode blocked tool call execution. 14 | - Fix layout rendering bug when no block is passed and views now render correctly without requiring a block. 15 | 16 | ### Removed 17 | - Generation Provider module and Action Prompt READMEs have been removed, but will be updated along with the main README in the next release. -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "ruby-openai", "~> 8.1.0" 6 | gem "anthropic", "~> 0.4.1" 7 | 8 | group :development, :test do 9 | gem "standard", require: false 10 | gem "rubocop-rails-omakase", require: false 11 | gem "puma" 12 | 13 | gem "sqlite3" 14 | gem "vcr" 15 | gem "webmock" 16 | end 17 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | activeagent (0.3.3) 5 | actionpack (>= 7.2, < 9.0) 6 | actionview (>= 7.2, < 9.0) 7 | activejob (>= 7.2, < 9.0) 8 | activemodel (>= 7.2, < 9.0) 9 | activesupport (>= 7.2, < 9.0) 10 | rails (>= 7.2, < 9.0) 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | actioncable (8.0.2) 16 | actionpack (= 8.0.2) 17 | activesupport (= 8.0.2) 18 | nio4r (~> 2.0) 19 | websocket-driver (>= 0.6.1) 20 | zeitwerk (~> 2.6) 21 | actionmailbox (8.0.2) 22 | actionpack (= 8.0.2) 23 | activejob (= 8.0.2) 24 | activerecord (= 8.0.2) 25 | activestorage (= 8.0.2) 26 | activesupport (= 8.0.2) 27 | mail (>= 2.8.0) 28 | actionmailer (8.0.2) 29 | actionpack (= 8.0.2) 30 | actionview (= 8.0.2) 31 | activejob (= 8.0.2) 32 | activesupport (= 8.0.2) 33 | mail (>= 2.8.0) 34 | rails-dom-testing (~> 2.2) 35 | actionpack (8.0.2) 36 | actionview (= 8.0.2) 37 | activesupport (= 8.0.2) 38 | nokogiri (>= 1.8.5) 39 | rack (>= 2.2.4) 40 | rack-session (>= 1.0.1) 41 | rack-test (>= 0.6.3) 42 | rails-dom-testing (~> 2.2) 43 | rails-html-sanitizer (~> 1.6) 44 | useragent (~> 0.16) 45 | actiontext (8.0.2) 46 | actionpack (= 8.0.2) 47 | activerecord (= 8.0.2) 48 | activestorage (= 8.0.2) 49 | activesupport (= 8.0.2) 50 | globalid (>= 0.6.0) 51 | nokogiri (>= 1.8.5) 52 | actionview (8.0.2) 53 | activesupport (= 8.0.2) 54 | builder (~> 3.1) 55 | erubi (~> 1.11) 56 | rails-dom-testing (~> 2.2) 57 | rails-html-sanitizer (~> 1.6) 58 | activejob (8.0.2) 59 | activesupport (= 8.0.2) 60 | globalid (>= 0.3.6) 61 | activemodel (8.0.2) 62 | activesupport (= 8.0.2) 63 | activerecord (8.0.2) 64 | activemodel (= 8.0.2) 65 | activesupport (= 8.0.2) 66 | timeout (>= 0.4.0) 67 | activestorage (8.0.2) 68 | actionpack (= 8.0.2) 69 | activejob (= 8.0.2) 70 | activerecord (= 8.0.2) 71 | activesupport (= 8.0.2) 72 | marcel (~> 1.0) 73 | activesupport (8.0.2) 74 | base64 75 | benchmark (>= 0.3) 76 | bigdecimal 77 | concurrent-ruby (~> 1.0, >= 1.3.1) 78 | connection_pool (>= 2.2.5) 79 | drb 80 | i18n (>= 1.6, < 2) 81 | logger (>= 1.4.2) 82 | minitest (>= 5.1) 83 | securerandom (>= 0.3) 84 | tzinfo (~> 2.0, >= 2.0.5) 85 | uri (>= 0.13.1) 86 | addressable (2.8.7) 87 | public_suffix (>= 2.0.2, < 7.0) 88 | anthropic (0.4.1) 89 | event_stream_parser (>= 0.3.0, < 2.0.0) 90 | faraday (>= 1) 91 | faraday-multipart (>= 1) 92 | ast (2.4.3) 93 | base64 (0.2.0) 94 | benchmark (0.4.0) 95 | bigdecimal (3.1.9) 96 | builder (3.3.0) 97 | concurrent-ruby (1.3.5) 98 | connection_pool (2.5.0) 99 | crack (1.0.0) 100 | bigdecimal 101 | rexml 102 | crass (1.0.6) 103 | date (3.4.1) 104 | drb (2.2.1) 105 | erubi (1.13.1) 106 | event_stream_parser (1.0.0) 107 | faraday (2.13.0) 108 | faraday-net_http (>= 2.0, < 3.5) 109 | json 110 | logger 111 | faraday-multipart (1.1.0) 112 | multipart-post (~> 2.0) 113 | faraday-net_http (3.4.0) 114 | net-http (>= 0.5.0) 115 | globalid (1.2.1) 116 | activesupport (>= 6.1) 117 | hashdiff (1.1.2) 118 | i18n (1.14.7) 119 | concurrent-ruby (~> 1.0) 120 | io-console (0.8.0) 121 | irb (1.15.2) 122 | pp (>= 0.6.0) 123 | rdoc (>= 4.0.0) 124 | reline (>= 0.4.2) 125 | json (2.10.2) 126 | language_server-protocol (3.17.0.4) 127 | lint_roller (1.1.0) 128 | logger (1.7.0) 129 | loofah (2.24.0) 130 | crass (~> 1.0.2) 131 | nokogiri (>= 1.12.0) 132 | mail (2.8.1) 133 | mini_mime (>= 0.1.1) 134 | net-imap 135 | net-pop 136 | net-smtp 137 | marcel (1.0.4) 138 | mini_mime (1.1.5) 139 | minitest (5.25.5) 140 | multipart-post (2.4.1) 141 | net-http (0.6.0) 142 | uri 143 | net-imap (0.5.6) 144 | date 145 | net-protocol 146 | net-pop (0.1.2) 147 | net-protocol 148 | net-protocol (0.2.2) 149 | timeout 150 | net-smtp (0.5.1) 151 | net-protocol 152 | nio4r (2.7.4) 153 | nokogiri (1.18.7-arm64-darwin) 154 | racc (~> 1.4) 155 | nokogiri (1.18.7-x86_64-linux-gnu) 156 | racc (~> 1.4) 157 | parallel (1.27.0) 158 | parser (3.3.8.0) 159 | ast (~> 2.4.1) 160 | racc 161 | pp (0.6.2) 162 | prettyprint 163 | prettyprint (0.2.0) 164 | prism (1.4.0) 165 | psych (5.2.3) 166 | date 167 | stringio 168 | public_suffix (6.0.1) 169 | puma (6.6.0) 170 | nio4r (~> 2.0) 171 | racc (1.8.1) 172 | rack (3.1.13) 173 | rack-session (2.1.0) 174 | base64 (>= 0.1.0) 175 | rack (>= 3.0.0) 176 | rack-test (2.2.0) 177 | rack (>= 1.3) 178 | rackup (2.2.1) 179 | rack (>= 3) 180 | rails (8.0.2) 181 | actioncable (= 8.0.2) 182 | actionmailbox (= 8.0.2) 183 | actionmailer (= 8.0.2) 184 | actionpack (= 8.0.2) 185 | actiontext (= 8.0.2) 186 | actionview (= 8.0.2) 187 | activejob (= 8.0.2) 188 | activemodel (= 8.0.2) 189 | activerecord (= 8.0.2) 190 | activestorage (= 8.0.2) 191 | activesupport (= 8.0.2) 192 | bundler (>= 1.15.0) 193 | railties (= 8.0.2) 194 | rails-dom-testing (2.2.0) 195 | activesupport (>= 5.0.0) 196 | minitest 197 | nokogiri (>= 1.6) 198 | rails-html-sanitizer (1.6.2) 199 | loofah (~> 2.21) 200 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 201 | railties (8.0.2) 202 | actionpack (= 8.0.2) 203 | activesupport (= 8.0.2) 204 | irb (~> 1.13) 205 | rackup (>= 1.0.0) 206 | rake (>= 12.2) 207 | thor (~> 1.0, >= 1.2.2) 208 | zeitwerk (~> 2.6) 209 | rainbow (3.1.1) 210 | rake (13.2.1) 211 | rdoc (6.13.1) 212 | psych (>= 4.0.0) 213 | regexp_parser (2.10.0) 214 | reline (0.6.1) 215 | io-console (~> 0.5) 216 | rexml (3.4.1) 217 | rubocop (1.75.2) 218 | json (~> 2.3) 219 | language_server-protocol (~> 3.17.0.2) 220 | lint_roller (~> 1.1.0) 221 | parallel (~> 1.10) 222 | parser (>= 3.3.0.2) 223 | rainbow (>= 2.2.2, < 4.0) 224 | regexp_parser (>= 2.9.3, < 3.0) 225 | rubocop-ast (>= 1.44.0, < 2.0) 226 | ruby-progressbar (~> 1.7) 227 | unicode-display_width (>= 2.4.0, < 4.0) 228 | rubocop-ast (1.44.1) 229 | parser (>= 3.3.7.2) 230 | prism (~> 1.4) 231 | rubocop-performance (1.25.0) 232 | lint_roller (~> 1.1) 233 | rubocop (>= 1.75.0, < 2.0) 234 | rubocop-ast (>= 1.38.0, < 2.0) 235 | rubocop-rails (2.31.0) 236 | activesupport (>= 4.2.0) 237 | lint_roller (~> 1.1) 238 | rack (>= 1.1) 239 | rubocop (>= 1.75.0, < 2.0) 240 | rubocop-ast (>= 1.38.0, < 2.0) 241 | rubocop-rails-omakase (1.1.0) 242 | rubocop (>= 1.72) 243 | rubocop-performance (>= 1.24) 244 | rubocop-rails (>= 2.30) 245 | ruby-openai (8.1.0) 246 | event_stream_parser (>= 0.3.0, < 2.0.0) 247 | faraday (>= 1) 248 | faraday-multipart (>= 1) 249 | ruby-progressbar (1.13.0) 250 | securerandom (0.4.1) 251 | sqlite3 (2.6.0-arm64-darwin) 252 | sqlite3 (2.6.0-x86_64-linux-gnu) 253 | standard (1.49.0) 254 | language_server-protocol (~> 3.17.0.2) 255 | lint_roller (~> 1.0) 256 | rubocop (~> 1.75.2) 257 | standard-custom (~> 1.0.0) 258 | standard-performance (~> 1.8) 259 | standard-custom (1.0.2) 260 | lint_roller (~> 1.0) 261 | rubocop (~> 1.50) 262 | standard-performance (1.8.0) 263 | lint_roller (~> 1.1) 264 | rubocop-performance (~> 1.25.0) 265 | stringio (3.1.6) 266 | thor (1.3.2) 267 | timeout (0.4.3) 268 | tzinfo (2.0.6) 269 | concurrent-ruby (~> 1.0) 270 | unicode-display_width (3.1.4) 271 | unicode-emoji (~> 4.0, >= 4.0.4) 272 | unicode-emoji (4.0.4) 273 | uri (1.0.3) 274 | useragent (0.16.11) 275 | vcr (6.3.1) 276 | base64 277 | webmock (3.25.1) 278 | addressable (>= 2.8.0) 279 | crack (>= 0.3.2) 280 | hashdiff (>= 0.4.0, < 2.0.0) 281 | websocket-driver (0.7.7) 282 | base64 283 | websocket-extensions (>= 0.1.0) 284 | websocket-extensions (0.1.5) 285 | zeitwerk (2.7.2) 286 | 287 | PLATFORMS 288 | arm64-darwin-23 289 | x86_64-linux 290 | 291 | DEPENDENCIES 292 | activeagent! 293 | anthropic (~> 0.4.1) 294 | puma 295 | rubocop-rails-omakase 296 | ruby-openai (~> 8.1.0) 297 | sqlite3 298 | standard 299 | vcr 300 | webmock 301 | 302 | BUNDLED WITH 303 | 2.6.5 304 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Active Agents AI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Active Agent 2 | 3 | ## Install 4 | 5 | ### Gemfile 6 | `gem 'activeagent'` 7 | 8 | ### CLI 9 | `gem install activeagent` 10 | 11 | ### Rails Generator 12 | After installing the gem, run the Rails installation generator: 13 | 14 | ```bash 15 | $ rails generate active_agent:install 16 | ``` 17 | 18 | This will create: 19 | ``` 20 | create config/active_agent.yml 21 | create app/agents/application_agent.rb 22 | create app/agents 23 | ``` 24 | 25 | - A YAML configuration file for provider settings, such as OpenAI and might include environment-specific configurations: 26 | 27 | ```yaml 28 | # config/active_agent.yml 29 | development: 30 | openai: 31 | service: "OpenAI" 32 | api_key: <%= Rails.application.credentials.dig(:openai, :api_key) %> 33 | model: "gpt-3.5-turbo" 34 | temperature: 0.7 35 | ollama: 36 | service: "Local Ollama" 37 | model: "llama3.2" 38 | temperature: 0.7 39 | 40 | production: 41 | openai: 42 | service: "OpenAI" 43 | api_key: <%= Rails.application.credentials.dig(:openai, :api_key) %> 44 | model: "gpt-3.5-turbo" 45 | temperature: 0.7 46 | 47 | ``` 48 | - A base application agent class 49 | ```ruby 50 | # app/agents/application_agent.rb 51 | class ApplicationAgent < ActiveAgent::Base 52 | layout 'agent' 53 | 54 | def prompt 55 | super { |format| format.text { render plain: params[:message] } } 56 | end 57 | ``` 58 | - The agents directory structure 59 | 60 | ## Agent 61 | Create agents that take instructions, prompts, and perform actions 62 | 63 | ### Rails Generator 64 | To use the Rails Active Agent generator to create a new agent and the associated views for the requested action prompts: 65 | 66 | ```bash 67 | $ rails generate active_agent:agent travel search book plans 68 | ``` 69 | This will create: 70 | ``` 71 | create app/agents/travel_agent.rb 72 | create app/views/agents/travel/search.text.erb 73 | create app/views/agents/travel/book.text.erb 74 | create app/views/agents/travel/plans.text.erb 75 | ``` 76 | 77 | The generator creates: 78 | - An agent class inheriting from ApplicationAgent 79 | - Text template views for each action 80 | - Action methods in the agent class for processing prompts 81 | 82 | ### Agent Actions 83 | ```ruby 84 | class TravelAgent < ApplicationAgent 85 | def search 86 | 87 | prompt { |format| format.text { render plain: "Searching for travel options" } } 88 | end 89 | 90 | def book 91 | prompt { |format| format.text { render plain: "Booking travel plans" } } 92 | end 93 | 94 | def plans 95 | prompt { |format| format.text { render plain: "Making travel plans" } } 96 | end 97 | end 98 | ``` 99 | 100 | ## Action Prompt 101 | 102 | Action Prompt provides the structured interface for composing AI interactions through messages, actions/tools, and conversation context. [Read more about Action Prompt](lib/active_agent/action_prompt/README.md) 103 | 104 | ```ruby 105 | agent.prompt(message: "Find hotels in Paris", 106 | actions: [{name: "search", params: {query: "hotels paris"}}]) 107 | ``` 108 | 109 | The prompt interface manages: 110 | - Message content and roles (system/user/assistant) 111 | - Action/tool definitions and requests 112 | - Headers and context tracking 113 | - Content types and multipart handling 114 | 115 | ### Generation Provider 116 | 117 | Generation Provider defines how prompts are sent to AI services for completion and embedding generation. [Read more about Generation Providers](lib/active_agent/generation_provider/README.md) 118 | 119 | ```ruby 120 | class VacationAgent < ActiveAgent::Base 121 | generate_with :openai, 122 | model: "gpt-4", 123 | temperature: 0.7 124 | 125 | embed_with :openai, 126 | model: "text-embedding-ada-002" 127 | end 128 | ``` 129 | 130 | Providers handle: 131 | - API client configuration 132 | - Prompt/completion generation 133 | - Stream processing 134 | - Embedding generation 135 | - Context management 136 | - Error handling 137 | 138 | ### Queue Generation 139 | 140 | Active Agent also supports queued generation with Active Job using a common Generation Job interface. 141 | 142 | ### Perform actions 143 | 144 | Active Agents can define methods that are autoloaded as callable tools. These actions’ default schema will be provided to the agent’s context as part of the prompt request to the Generation Provider. 145 | 146 | ## Actions 147 | 148 | ```ruby 149 | def get_cat_image_base64 150 | uri = URI("https://cataas.com/cat") 151 | response = Net::HTTP.get_response(uri) 152 | 153 | if response.is_a?(Net::HTTPSuccess) 154 | image_data = response.body 155 | Base64.strict_encode64(image_data) 156 | else 157 | raise "Failed to fetch cat image. Status code: #{response.code}" 158 | end 159 | end 160 | 161 | class SupportAgent < ActiveAgent 162 | generate_with :openai, 163 | model: "gpt-4o", 164 | instructions: "Help people with their problems", 165 | temperature: 0.7 166 | 167 | def get_cat_image 168 | prompt { |format| format.text { render plain: get_cat_image_base64 } } 169 | end 170 | end 171 | ``` 172 | 173 | ## Prompts 174 | 175 | ### Basic 176 | 177 | #### Plain text prompt and response templates 178 | 179 | ### HTML 180 | 181 | ### Action Schema JSON 182 | 183 | response = SupportAgent.prompt(‘show me a picture of a cat’).generate_now 184 | 185 | response.message 186 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | require "bundler/gem_tasks" 4 | -------------------------------------------------------------------------------- /activeagent.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/active_agent/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "activeagent" 5 | spec.version = ActiveAgent::VERSION 6 | spec.summary = "Rails AI Agents Framework" 7 | spec.description = "The only agent-oriented AI framework designed for Rails, where Agents are Controllers. Build AI features with less complexity using the MVC conventions you love." 8 | spec.authors = [ "Justin Bowen" ] 9 | spec.email = "jusbowen@gmail.com" 10 | spec.files = Dir["CHANGELOG.md", "README.rdoc", "MIT-LICENSE", "lib/**/*"] 11 | spec.require_paths = "lib" 12 | spec.homepage = "https://activeagents.ai" 13 | spec.license = "MIT" 14 | 15 | spec.metadata = { 16 | "bug_tracker_uri" => "https://github.com/activeagents/activeagent/issues", 17 | "documentation_uri" => "https://github.com/activeagents/activeagent", 18 | "source_code_uri" => "https://github.com/activeagents/activeagent", 19 | "rubygems_mfa_required" => "true" 20 | } 21 | # Add dependencies 22 | spec.add_dependency "actionpack", ">= 7.2", "< 9.0" 23 | spec.add_dependency "actionview", ">= 7.2", "< 9.0" 24 | spec.add_dependency "activesupport", ">= 7.2", "< 9.0" 25 | spec.add_dependency "activemodel", ">= 7.2", "< 9.0" 26 | spec.add_dependency "activejob", ">= 7.2", "< 9.0" 27 | 28 | spec.add_dependency "rails", ">= 7.2", "< 9.0" 29 | end 30 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | # explicit rubocop config increases performance slightly while avoiding config confusion. 6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 7 | 8 | load Gem.bin_path("rubocop", "rubocop") 9 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("../test", __dir__) 3 | 4 | require "bundler/setup" 5 | require "rails/plugin/test" 6 | -------------------------------------------------------------------------------- /fixtures/vcr_cassettes/ollama_text_prompt_response.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: http://localhost:11434/v1/chat/completions 6 | body: 7 | encoding: UTF-8 8 | string: '{"model":"gemma3:latest","messages":[{"role":"system","content":"You''re 9 | a basic Ollama agent.","type":"text/plain","charset":"UTF-8"},{"role":"user","content":"Show 10 | me a cat","type":"text/plain","charset":"UTF-8"}],"temperature":0.7,"tools":null}' 11 | headers: 12 | Content-Type: 13 | - application/json 14 | Authorization: 15 | - Bearer 16 | Accept-Encoding: 17 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 18 | Accept: 19 | - "*/*" 20 | User-Agent: 21 | - Ruby 22 | response: 23 | status: 24 | code: 200 25 | message: OK 26 | headers: 27 | Content-Type: 28 | - application/json 29 | Date: 30 | - Tue, 29 Apr 2025 07:33:26 GMT 31 | Content-Length: 32 | - '703' 33 | body: 34 | encoding: ASCII-8BIT 35 | string: !binary |- 36 | eyJpZCI6ImNoYXRjbXBsLTk4MiIsIm9iamVjdCI6ImNoYXQuY29tcGxldGlvbiIsImNyZWF0ZWQiOjE3NDU5MTIwMDYsIm1vZGVsIjoiZ2VtbWEzOmxhdGVzdCIsInN5c3RlbV9maW5nZXJwcmludCI6ImZwX29sbGFtYSIsImNob2ljZXMiOlt7ImluZGV4IjowLCJtZXNzYWdlIjp7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjoiT2theSEgSGVyZSdzIGEgcGljdHVyZSBvZiBhIGN1dGUgY2F0OlxuXG4oSeKAmW0gYSB0ZXh0LWJhc2VkIGFnZW50LCBzbyBJIGNhbuKAmXQgKmFjdHVhbGx5KiBzaG93IHlvdSBhIHBpY3R1cmUuIEJ1dCBJ4oCZdmUgZ2VuZXJhdGVkIGEgZGVzY3JpcHRpb24gb2YgYSBmbHVmZnkgdGFiYnkgY2F0IGZvciB5b3UgdG8gaW1hZ2luZSEpIFxuXG5JbWFnaW5lIGEgZ2luZ2VyIHRhYmJ5IGNhdCwgY3VybGVkIHVwIGFzbGVlcCBpbiBhIHN1bmJlYW0uICBJdCBoYXMgYnJpZ2h0IGdyZWVuIGV5ZXMgYW5kIGEgbGl0dGxlIHBpbmsgbm9zZS4g8J+YiiBcblxuV291bGQgeW91IGxpa2UgbWUgdG8gdHJ5IGdlbmVyYXRpbmcgYSBkaWZmZXJlbnQgaW1hZ2UgcHJvbXB0LCBvciBwZXJoYXBzIHRlbGwgeW91IGEgY2F0LXJlbGF0ZWQgZmFjdD8ifSwiZmluaXNoX3JlYXNvbiI6InN0b3AifV0sInVzYWdlIjp7InByb21wdF90b2tlbnMiOjI3LCJjb21wbGV0aW9uX3Rva2VucyI6MTA3LCJ0b3RhbF90b2tlbnMiOjEzNH19Cg== 37 | recorded_at: Tue, 29 Apr 2025 07:33:26 GMT 38 | recorded_with: VCR 6.3.1 39 | -------------------------------------------------------------------------------- /fixtures/vcr_cassettes/open_router_text_prompt_response.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://openrouter.ai/api/v1/chat/completions 6 | body: 7 | encoding: UTF-8 8 | string: '{"model":"qwen/qwen3-30b-a3b:free","messages":[{"role":"system","content":"You''re 9 | a basic OpenAI agent.","type":"text/plain","charset":"UTF-8"},{"role":"user","content":"Show 10 | me a cat","type":"text/plain","charset":"UTF-8"}],"temperature":0.7,"tools":null}' 11 | headers: 12 | Content-Type: 13 | - application/json 14 | Authorization: 15 | - Bearer 16 | Accept-Encoding: 17 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 18 | Accept: 19 | - "*/*" 20 | User-Agent: 21 | - Ruby 22 | response: 23 | status: 24 | code: 200 25 | message: OK 26 | headers: 27 | Date: 28 | - Tue, 29 Apr 2025 07:12:31 GMT 29 | Content-Type: 30 | - application/json 31 | Transfer-Encoding: 32 | - chunked 33 | Connection: 34 | - keep-alive 35 | Access-Control-Allow-Origin: 36 | - "*" 37 | X-Clerk-Auth-Message: 38 | - Invalid JWT form. A JWT consists of three parts separated by dots. (reason=token-invalid, 39 | token-carrier=header) 40 | X-Clerk-Auth-Reason: 41 | - token-invalid 42 | X-Clerk-Auth-Status: 43 | - signed-out 44 | Vary: 45 | - Accept-Encoding 46 | Server: 47 | - cloudflare 48 | Cf-Ray: 49 | - 937cfdcfba5e7eef-KIX 50 | body: 51 | encoding: ASCII-8BIT 52 | string: !binary |- 53 | CiAgICAgICAgIAoKICAgICAgICAgCgogICAgICAgICAKCiAgICAgICAgIAoKICAgICAgICAgCgogICAgICAgICAKCiAgICAgICAgIAoKICAgICAgICAgCgogICAgICAgICAKCiAgICAgICAgIAoKICAgICAgICAgCnsiaWQiOiJnZW4tMTc0NTkxMDc1MS1ObzFEMGpYeVNRdTJ1YkJURWZ6ZiIsInByb3ZpZGVyIjoiQ2h1dGVzIiwibW9kZWwiOiJxd2VuL3F3ZW4zLTMwYi1hM2I6ZnJlZSIsIm9iamVjdCI6ImNoYXQuY29tcGxldGlvbiIsImNyZWF0ZWQiOjE3NDU5MTA3NTEsImNob2ljZXMiOlt7ImxvZ3Byb2JzIjpudWxsLCJmaW5pc2hfcmVhc29uIjoic3RvcCIsIm5hdGl2ZV9maW5pc2hfcmVhc29uIjoic3RvcCIsImluZGV4IjowLCJtZXNzYWdlIjp7InJvbGUiOiJhc3Npc3RhbnQiLCJjb250ZW50IjoiSSBjYW4ndCBkaXNwbGF5IGltYWdlcywgYnV0IEkgY2FuIGRlc2NyaWJlIGEgY2F0IGZvciB5b3UhIPCfkLEgIFxuXG5JbWFnaW5lIGEgZmx1ZmZ5IGNyZWF0dXJlIHdpdGggc29mdCBmdXIsIHR3aXRjaGluZyB3aGlza2VycywgYW5kIGJyaWdodCwgY3VyaW91cyBleWVzLiBJdCBtaWdodCBoYXZlIGVhcnMgdGhhdCBzd2l2ZWwgdG8gY2F0Y2ggc291bmRzLCBhIHRhaWwgdGhhdCBmbGlja3Mgd2hlbiBpdCdzIGhhcHB5IG9yIGFubm95ZWQsIGFuZCBwYXdzIHRoYXQgcGFkIHNpbGVudGx5IGFjcm9zcyB0aGUgZmxvb3IuIENhdHMgY29tZSBpbiBhbGwgY29sb3JzIGFuZCBwYXR0ZXJuc+KAlHN0cmlwZXMsIHNwb3RzLCBzb2xpZCBodWVz4oCUYW5kIHNvbWUgaGF2ZSBsb25nIGhhaXIgbGlrZSBhIFBlcnNpYW4gb3Igc2hvcnQgaGFpciBsaWtlIGEgdGFiYnkuIFRoZXkgbG92ZSB0byBuYXAsIGNoYXNlIHRveXMsIGFuZCBzb21ldGltZXMgc3RhcmUgYXQgbm90aGluZyB3aXRoIGludGVuc2UgZm9jdXMuIFdhbnQgbWUgdG8gcGFpbnQgYSBtb3JlIHNwZWNpZmljIHNjZW5lPyDwn5i6IiwicmVmdXNhbCI6bnVsbCwicmVhc29uaW5nIjoiT2theSwgdGhlIHVzZXIgYXNrZWQgdG8gc2VlIGEgY2F0LiBTaW5jZSBJIGNhbid0IGRpc3BsYXkgaW1hZ2VzLCBJIG5lZWQgdG8gZGVzY3JpYmUgb25lLiBMZXQgbWUgdGhpbmsgYWJvdXQgdGhlIGtleSBmZWF0dXJlcyBvZiBhIGNhdC4gVGhleSBoYXZlIGZ1ciwgd2hpc2tlcnMsIGVhcnMsIGEgdGFpbCwgYW5kIHRoZXkncmUgdXN1YWxseSBwbGF5ZnVsLiBNYXliZSBtZW50aW9uIGRpZmZlcmVudCBjb2xvcnMgYW5kIGJyZWVkcy4gQWxzbywgY2F0cyBoYXZlIHNwZWNpZmljIGJlaGF2aW9ycyBsaWtlIHB1cnJpbmcgb3Igc3RyZXRjaGluZy4gSSBzaG91bGQga2VlcCBpdCBmcmllbmRseSBhbmQgZW5nYWdpbmcuIExldCBtZSBwdXQgdGhhdCB0b2dldGhlciBpbiBhIHNpbXBsZSwgdml2aWQgd2F5LlxuIn19XSwidXNhZ2UiOnsicHJvbXB0X3Rva2VucyI6MjMsImNvbXBsZXRpb25fdG9rZW5zIjoyMTksInRvdGFsX3Rva2VucyI6MjQyfX0= 54 | recorded_at: Tue, 29 Apr 2025 07:12:35 GMT 55 | recorded_with: VCR 6.3.1 56 | -------------------------------------------------------------------------------- /fixtures/vcr_cassettes/openai_text_prompt_response.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://api.openai.com/v1/chat/completions 6 | body: 7 | encoding: UTF-8 8 | string: '{"model":"gpt-4o-mini","messages":[{"role":"system","content":"You''re 9 | a basic OpenAI agent.","type":"text/plain","charset":"UTF-8"},{"role":"user","content":"Show 10 | me a cat","type":"text/plain","charset":"UTF-8"}],"temperature":0.7,"tools":null}' 11 | headers: 12 | Content-Type: 13 | - application/json 14 | Authorization: 15 | - Bearer 16 | Accept-Encoding: 17 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 18 | Accept: 19 | - "*/*" 20 | User-Agent: 21 | - Ruby 22 | response: 23 | status: 24 | code: 200 25 | message: OK 26 | headers: 27 | Date: 28 | - Tue, 29 Apr 2025 07:12:23 GMT 29 | Content-Type: 30 | - application/json 31 | Transfer-Encoding: 32 | - chunked 33 | Connection: 34 | - keep-alive 35 | Access-Control-Expose-Headers: 36 | - X-Request-ID 37 | Openai-Organization: 38 | - user-lwlf4w2yvortlzept3wqx7li 39 | Openai-Processing-Ms: 40 | - '1958' 41 | Openai-Version: 42 | - '2020-10-01' 43 | X-Ratelimit-Limit-Requests: 44 | - '10000' 45 | X-Ratelimit-Limit-Tokens: 46 | - '200000' 47 | X-Ratelimit-Remaining-Requests: 48 | - '9998' 49 | X-Ratelimit-Remaining-Tokens: 50 | - '199986' 51 | X-Ratelimit-Reset-Requests: 52 | - 9.268s 53 | X-Ratelimit-Reset-Tokens: 54 | - 4ms 55 | X-Request-Id: 56 | - req_0de0ac689fdad9f239eade97c20dc2ec 57 | Strict-Transport-Security: 58 | - max-age=31536000; includeSubDomains; preload 59 | Cf-Cache-Status: 60 | - DYNAMIC 61 | Set-Cookie: 62 | - __cf_bm=1XVyhD89Hou7QBFwKbxPD.PnGq_upC_p.sucP41655A-1745910743-1.0.1.1-9MPpSWG2l2aJZZkle6QuKkmHPjOXpPtVBw3M35fSKhcgfevI5xaZ7YF4YtwfNH3C_CQDh0x3c751ZC4m6qtaeS0.Ag.onyIdnsVneFGNr.E; 63 | path=/; expires=Tue, 29-Apr-25 07:42:23 GMT; domain=.api.openai.com; HttpOnly; 64 | Secure; SameSite=None 65 | - _cfuvid=t.5WVSSFIDl5AsikKx9nAzwtNH5WdOIaSELrCgi13Wo-1745910743619-0.0.1.1-604800000; 66 | path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None 67 | X-Content-Type-Options: 68 | - nosniff 69 | Server: 70 | - cloudflare 71 | Cf-Ray: 72 | - 937cfd963a5c8311-KIX 73 | Alt-Svc: 74 | - h3=":443"; ma=86400 75 | body: 76 | encoding: ASCII-8BIT 77 | string: !binary |- 78 | ewogICJpZCI6ICJjaGF0Y21wbC1CUlpRYmFDSVFyS241Qmp6ZjNRSmtWZFZ6dkhXTyIsCiAgIm9iamVjdCI6ICJjaGF0LmNvbXBsZXRpb24iLAogICJjcmVhdGVkIjogMTc0NTkxMDc0MSwKICAibW9kZWwiOiAiZ3B0LTRvLW1pbmktMjAyNC0wNy0xOCIsCiAgImNob2ljZXMiOiBbCiAgICB7CiAgICAgICJpbmRleCI6IDAsCiAgICAgICJtZXNzYWdlIjogewogICAgICAgICJyb2xlIjogImFzc2lzdGFudCIsCiAgICAgICAgImNvbnRlbnQiOiAiSSBjYW4ndCBkaXNwbGF5IGltYWdlcyBkaXJlY3RseSwgYnV0IEkgY2FuIGRlc2NyaWJlIGEgY2F0IGZvciB5b3UhIFxuXG5JbWFnaW5lIGEgZmx1ZmZ5IGNhdCB3aXRoIHNvZnQgZnVyLCBicmlnaHQgZ3JlZW4gZXllcywgYW5kIGEgcGxheWZ1bCBkZW1lYW5vci4gSXRzIHdoaXNrZXJzIHR3aXRjaCBhcyBpdCBjdXJpb3VzbHkgZXhwbG9yZXMgaXRzIHN1cnJvdW5kaW5ncy4gSXQgbWlnaHQgYmUgY3VybGVkIHVwIGluIGEgc3Vubnkgc3BvdCwgcHVycmluZyBjb250ZW50ZWRseSwgb3IgcGxheWZ1bGx5IGNoYXNpbmcgYSB0b3kgbW91c2UuIElmIHlvdeKAmWQgbGlrZSwgSSBjYW4gYWxzbyBwcm92aWRlIGluZm9ybWF0aW9uIGFib3V0IGRpZmZlcmVudCBjYXQgYnJlZWRzIG9yIHRpcHMgb24gY2F0IGNhcmUhIiwKICAgICAgICAicmVmdXNhbCI6IG51bGwsCiAgICAgICAgImFubm90YXRpb25zIjogW10KICAgICAgfSwKICAgICAgImxvZ3Byb2JzIjogbnVsbCwKICAgICAgImZpbmlzaF9yZWFzb24iOiAic3RvcCIKICAgIH0KICBdLAogICJ1c2FnZSI6IHsKICAgICJwcm9tcHRfdG9rZW5zIjogMjIsCiAgICAiY29tcGxldGlvbl90b2tlbnMiOiA4OSwKICAgICJ0b3RhbF90b2tlbnMiOiAxMTEsCiAgICAicHJvbXB0X3Rva2Vuc19kZXRhaWxzIjogewogICAgICAiY2FjaGVkX3Rva2VucyI6IDAsCiAgICAgICJhdWRpb190b2tlbnMiOiAwCiAgICB9LAogICAgImNvbXBsZXRpb25fdG9rZW5zX2RldGFpbHMiOiB7CiAgICAgICJyZWFzb25pbmdfdG9rZW5zIjogMCwKICAgICAgImF1ZGlvX3Rva2VucyI6IDAsCiAgICAgICJhY2NlcHRlZF9wcmVkaWN0aW9uX3Rva2VucyI6IDAsCiAgICAgICJyZWplY3RlZF9wcmVkaWN0aW9uX3Rva2VucyI6IDAKICAgIH0KICB9LAogICJzZXJ2aWNlX3RpZXIiOiAiZGVmYXVsdCIsCiAgInN5c3RlbV9maW5nZXJwcmludCI6ICJmcF8xMjlhMzYzNTJhIgp9Cg== 79 | recorded_at: Tue, 29 Apr 2025 07:12:23 GMT 80 | recorded_with: VCR 6.3.1 81 | -------------------------------------------------------------------------------- /lib/active_agent.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | require "abstract_controller" 3 | require "active_agent/action_prompt" 4 | require "active_agent/generation_provider" 5 | require "active_agent/version" 6 | require "active_agent/deprecator" 7 | require "active_agent/railtie" if defined?(Rails) 8 | 9 | require "active_support" 10 | require "active_support/rails" 11 | require "active_support/core_ext/class" 12 | require "active_support/core_ext/module/attr_internal" 13 | require "active_support/core_ext/string/inflections" 14 | require "active_support/lazy_load_hooks" 15 | module ActiveAgent 16 | extend ActiveSupport::Autoload 17 | 18 | eager_autoload do 19 | autoload :Collector 20 | end 21 | 22 | autoload :Base 23 | autoload :Callbacks 24 | autoload :InlinePreviewInterceptor 25 | autoload :PromptHelper 26 | autoload :Generation 27 | autoload :GenerationMethods 28 | autoload :GenerationProvider 29 | autoload :QueuedGeneration 30 | autoload :Parameterized 31 | autoload :Preview 32 | autoload :Previews, "active_agent/preview" 33 | autoload :GenerationJob 34 | 35 | class << self 36 | attr_accessor :config 37 | 38 | def eager_load! 39 | super 40 | 41 | Base.descendants.each do |agent| 42 | agent.eager_load! unless agent.abstract? 43 | end 44 | end 45 | 46 | def configure 47 | yield self 48 | end 49 | 50 | def load_configuration(file) 51 | if File.exist?(file) 52 | config_file = YAML.load(ERB.new(File.read(file)).result, aliases: true) 53 | env = ENV["RAILS_ENV"] || ENV["ENV"] || "development" 54 | @config = config_file[env] || config_file 55 | end 56 | end 57 | end 58 | end 59 | 60 | autoload :Mime, "action_dispatch/http/mime_type" 61 | 62 | ActiveSupport.on_load(:action_view) do 63 | ActionView::Base.default_formats ||= Mime::SET.symbols 64 | ActionView::Template.mime_types_implementation = Mime 65 | ActionView::LookupContext::DetailsKey.clear 66 | end 67 | -------------------------------------------------------------------------------- /lib/active_agent/README.md: -------------------------------------------------------------------------------- 1 | # Active Agent 2 | Active Agent is a Rails framework for creating and managing AI agents. It provides a structured way to interact with generation providers through agents with context including prompts, tools, and messages. It includes two core modules, Generation Provider and Action Prompt, along with several support classes to handle different aspects of agent creation and management. 3 | 4 | ## Core Modules 5 | 6 | - Generation Provider - module for configuring and interacting with generation providers through Prompts and Responses. 7 | - Action Prompt - module for defining prompts, tools, and messages. The Base class implements an AbstractController to render prompts and actions. Prompts are Action Views that provide instructions for the agent to generate content, formatted messages for the agent and users including **streaming generative UI**. 8 | 9 | ## Main Components 10 | 11 | - Base class - for creating and configuring agents. 12 | - Queued Generation - module for managing queued generation requests and responses. Using the Generation Job class to perform asynchronous generation requests, it provides a way to **stream generation** requests back to the Job, Agent, or User. 13 | 14 | ### ActiveAgent::Base 15 | 16 | The Base class is used to create agents that interact with generation providers through prompts and messages. It includes methods for configuring and interacting with generation providers using Prompts and Responses. The Base class also provides a structured way to render prompts and actions. 17 | 18 | #### Core Methods 19 | 20 | - `generate_with(provider, options = {})` - Configures the agent to generate content using the specified generation provider and options. 21 | - `streams_with(provider, options = {})` - Configures the agent to stream content using the specified generation provider's stream option. 22 | -------------------------------------------------------------------------------- /lib/active_agent/action_prompt.rb: -------------------------------------------------------------------------------- 1 | require "abstract_controller" 2 | require "active_support/core_ext/string/inflections" 3 | 4 | module ActiveAgent 5 | module ActionPrompt 6 | extend ::ActiveSupport::Autoload 7 | 8 | eager_autoload do 9 | autoload :Collector 10 | autoload :Message 11 | autoload :Prompt 12 | autoload :PromptHelper 13 | end 14 | 15 | autoload :Base 16 | 17 | extend ActiveSupport::Concern 18 | 19 | included do 20 | include AbstractController::Rendering 21 | include AbstractController::Layouts 22 | include AbstractController::Helpers 23 | include AbstractController::Translation 24 | include AbstractController::AssetPaths 25 | include AbstractController::Callbacks 26 | include AbstractController::Caching 27 | 28 | include ActionView::Layouts 29 | 30 | helper ActiveAgent::PromptHelper 31 | # class_attribute :default_params, default: { 32 | # content_type: "text/plain", 33 | # parts_order: ["text/plain", "text/html", "application/json"] 34 | # }.freeze 35 | end 36 | 37 | class TestAgent 38 | class << self 39 | attr_accessor :generations 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/active_agent/action_prompt/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/lib/active_agent/action_prompt/README.md -------------------------------------------------------------------------------- /lib/active_agent/action_prompt/action.rb: -------------------------------------------------------------------------------- 1 | module ActiveAgent 2 | module ActionPrompt 3 | class Action 4 | attr_accessor :agent_name, :id, :name, :params 5 | 6 | def initialize(attributes = {}) 7 | @id = attributes.fetch(:id, nil) 8 | @name = attributes.fetch(:name, "") 9 | @params = attributes.fetch(:params, {}) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/active_agent/action_prompt/message.rb: -------------------------------------------------------------------------------- 1 | module ActiveAgent 2 | module ActionPrompt 3 | class Message 4 | VALID_ROLES = %w[system assistant user tool function].freeze 5 | 6 | attr_accessor :action_id, :content, :role, :action_requested, :requested_actions, :content_type, :charset 7 | 8 | def initialize(attributes = {}) 9 | @action_id = attributes[:action_id] 10 | @charset = attributes[:charset] || "UTF-8" 11 | @content = attributes[:content] || "" 12 | @content_type = attributes[:content_type] || "text/plain" 13 | @role = attributes[:role] || :user 14 | @requested_actions = attributes[:requested_actions] || [] 15 | @action_requested = @requested_actions.any? 16 | validate_role 17 | end 18 | 19 | def to_s 20 | @content.to_s 21 | end 22 | 23 | def to_h 24 | hash = { 25 | role: role, 26 | action_id: action_id, 27 | content: content, 28 | type: content_type, 29 | charset: charset 30 | } 31 | 32 | hash[:action_requested] = requested_actions.any? 33 | hash[:requested_actions] = requested_actions if requested_actions.any? 34 | hash 35 | end 36 | 37 | def embed 38 | @agent_class.embed(@content) 39 | end 40 | 41 | private 42 | 43 | def validate_role 44 | unless VALID_ROLES.include?(role.to_s) 45 | raise ArgumentError, "Invalid role: #{role}. Valid roles are: #{VALID_ROLES.join(", ")}" 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/active_agent/action_prompt/prompt.rb: -------------------------------------------------------------------------------- 1 | require_relative "message" 2 | 3 | module ActiveAgent 4 | module ActionPrompt 5 | class Prompt 6 | attr_reader :messages 7 | attr_accessor :actions, :body, :content_type, :context_id, :instructions, :message, :options, :mime_version, :charset, :context, :parts, :params, :action_choice, :agent_class 8 | 9 | def initialize(attributes = {}) 10 | @options = attributes.fetch(:options, {}) 11 | @agent_class = attributes.fetch(:agent_class, ApplicationAgent) 12 | @actions = attributes.fetch(:actions, []) 13 | @action_choice = attributes.fetch(:action_choice, "") 14 | @instructions = attributes.fetch(:instructions, "") 15 | @body = attributes.fetch(:body, "") 16 | @content_type = attributes.fetch(:content_type, "text/plain") 17 | @message = attributes.fetch(:message, nil) 18 | @messages = attributes.fetch(:messages, []) 19 | @params = attributes.fetch(:params, {}) 20 | @mime_version = attributes.fetch(:mime_version, "1.0") 21 | @charset = attributes.fetch(:charset, "UTF-8") 22 | @context = attributes.fetch(:context, []) 23 | @context_id = attributes.fetch(:context_id, nil) 24 | @headers = attributes.fetch(:headers, {}) 25 | @parts = attributes.fetch(:parts, []) 26 | 27 | set_message if attributes[:message].is_a?(String) || @body.is_a?(String) && @message&.content 28 | set_messages 29 | end 30 | 31 | def messages=(messages) 32 | @messages = messages 33 | set_messages 34 | end 35 | 36 | # Generate the prompt as a string (for debugging or sending to the provider) 37 | def to_s 38 | @message.to_s 39 | end 40 | 41 | def add_part(message) 42 | @message = message 43 | set_message if @content_type == message.content_type && @message.content.present? 44 | 45 | @parts << context 46 | end 47 | 48 | def multipart? 49 | @parts.any? 50 | end 51 | 52 | def to_h 53 | { 54 | actions: @actions, 55 | action: @action_choice, 56 | instructions: @instructions, 57 | message: @message.to_h, 58 | messages: @messages.map(&:to_h), 59 | headers: @headers, 60 | context: @context 61 | } 62 | end 63 | 64 | def headers(headers = {}) 65 | @headers.merge!(headers) 66 | end 67 | 68 | private 69 | 70 | def set_messages 71 | @messages = [ Message.new(content: @instructions, role: :system) ] + @messages if @instructions.present? 72 | end 73 | 74 | def set_message 75 | if @message.is_a? String 76 | @message = Message.new(content: @message, role: :user) 77 | elsif @body.is_a?(String) && @message.content.blank? 78 | @message = Message.new(content: @body, role: :user) 79 | end 80 | 81 | @messages << @message 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/active_agent/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_agent/prompt_helper" 4 | require "active_agent/action_prompt/prompt" 5 | require "active_agent/collector" 6 | require "active_support/core_ext/string/inflections" 7 | require "active_support/core_ext/hash/except" 8 | require "active_support/core_ext/module/anonymous" 9 | 10 | # require "active_agent/log_subscriber" 11 | require "active_agent/rescuable" 12 | 13 | # The ActiveAgent module provides a framework for creating agents that can generate content 14 | # and handle various actions. The Base class within this module extends AbstractController::Base 15 | # and includes several modules to provide additional functionality such as callbacks, generation 16 | # methods, and rescuable actions. 17 | # 18 | # The Base class defines several class methods for registering and unregistering observers and 19 | # interceptors, as well as methods for generating content with a specified provider and streaming 20 | # content. It also provides methods for setting default parameters and handling prompts. 21 | # 22 | # The instance methods in the Base class include methods for performing generation, processing 23 | # actions, and handling headers and attachments. The class also defines a NullPrompt class for 24 | # handling cases where no prompt is provided. 25 | # 26 | # The Base class uses ActiveSupport::Notifications for instrumentation and provides several 27 | # private methods for setting payloads, applying defaults, and collecting responses from blocks, 28 | # text, or templates. 29 | # 30 | # The class also includes several protected instance variables and defines hooks for loading 31 | # additional functionality. 32 | module ActiveAgent 33 | class Base < AbstractController::Base 34 | include Callbacks 35 | include GenerationMethods 36 | include GenerationProvider 37 | include QueuedGeneration 38 | include Rescuable 39 | include Parameterized 40 | include Previews 41 | # include FormBuilder 42 | 43 | abstract! 44 | 45 | include AbstractController::Rendering 46 | 47 | include AbstractController::Logger 48 | include AbstractController::Helpers 49 | include AbstractController::Translation 50 | include AbstractController::AssetPaths 51 | include AbstractController::Callbacks 52 | include AbstractController::Caching 53 | 54 | include ActionView::Layouts 55 | 56 | PROTECTED_IVARS = AbstractController::Rendering::DEFAULT_PROTECTED_INSTANCE_VARIABLES + [ :@_action_has_layout ] 57 | 58 | helper ActiveAgent::PromptHelper 59 | 60 | class_attribute :options 61 | 62 | class_attribute :default_params, default: { 63 | mime_version: "1.0", 64 | charset: "UTF-8", 65 | content_type: "text/plain", 66 | parts_order: [ "text/plain", "text/enriched", "text/html" ] 67 | }.freeze 68 | 69 | class << self 70 | # Register one or more Observers which will be notified when prompt is generated. 71 | def register_observers(*observers) 72 | observers.flatten.compact.each { |observer| register_observer(observer) } 73 | end 74 | 75 | # Unregister one or more previously registered Observers. 76 | def unregister_observers(*observers) 77 | observers.flatten.compact.each { |observer| unregister_observer(observer) } 78 | end 79 | 80 | # Register one or more Interceptors which will be called before prompt is sent. 81 | def register_interceptors(*interceptors) 82 | interceptors.flatten.compact.each { |interceptor| register_interceptor(interceptor) } 83 | end 84 | 85 | # Unregister one or more previously registered Interceptors. 86 | def unregister_interceptors(*interceptors) 87 | interceptors.flatten.compact.each { |interceptor| unregister_interceptor(interceptor) } 88 | end 89 | 90 | # Register an Observer which will be notified when prompt is generated. 91 | # Either a class, string, or symbol can be passed in as the Observer. 92 | # If a string or symbol is passed in it will be camelized and constantized. 93 | def register_observer(observer) 94 | Prompt.register_observer(observer_class_for(observer)) 95 | end 96 | 97 | # Unregister a previously registered Observer. 98 | # Either a class, string, or symbol can be passed in as the Observer. 99 | # If a string or symbol is passed in it will be camelized and constantized. 100 | def unregister_observer(observer) 101 | Prompt.unregister_observer(observer_class_for(observer)) 102 | end 103 | 104 | # Register an Interceptor which will be called before prompt is sent. 105 | # Either a class, string, or symbol can be passed in as the Interceptor. 106 | # If a string or symbol is passed in it will be camelized and constantized. 107 | def register_interceptor(interceptor) 108 | Prompt.register_interceptor(observer_class_for(interceptor)) 109 | end 110 | 111 | # Unregister a previously registered Interceptor. 112 | # Either a class, string, or symbol can be passed in as the Interceptor. 113 | # If a string or symbol is passed in it will be camelized and constantized. 114 | def unregister_interceptor(interceptor) 115 | Prompt.unregister_interceptor(observer_class_for(interceptor)) 116 | end 117 | 118 | def observer_class_for(value) # :nodoc: 119 | case value 120 | when String, Symbol 121 | value.to_s.camelize.constantize 122 | else 123 | value 124 | end 125 | end 126 | private :observer_class_for 127 | 128 | # Define how the agent should generate content 129 | def generate_with(provider, **options) 130 | self.generation_provider = provider 131 | self.options = (options || {}).merge(options) 132 | self.options[:stream] = new.agent_stream if self.options[:stream] 133 | generation_provider.config.merge!(self.options) 134 | end 135 | 136 | def stream_with(&stream) 137 | self.options = (options || {}).merge(stream: stream) 138 | end 139 | 140 | # Returns the name of the current agent. This method is also being used as a path for a view lookup. 141 | # If this is an anonymous agent, this method will return +anonymous+ instead. 142 | def agent_name 143 | @agent_name ||= anonymous? ? "anonymous" : name.underscore 144 | end 145 | # Allows to set the name of current agent. 146 | attr_writer :agent_name 147 | alias_method :controller_path, :agent_name 148 | 149 | # Sets the defaults through app configuration: 150 | # 151 | # config.action_agent.default(from: "no-reply@example.org") 152 | # 153 | # Aliased by ::default_options= 154 | def default(value = nil) 155 | self.default_params = default_params.merge(value).freeze if value 156 | default_params 157 | end 158 | # Allows to set defaults through app configuration: 159 | # 160 | # config.action_agent.default_options = { from: "no-reply@example.org" } 161 | alias_method :default_options=, :default 162 | 163 | # Wraps a prompt generation inside of ActiveSupport::Notifications instrumentation. 164 | # 165 | # This method is actually called by the +ActionPrompt::Prompt+ object itself 166 | # through a callback when you call :generate_prompt on the +ActionPrompt::Prompt+, 167 | # calling +generate_prompt+ directly and passing an +ActionPrompt::Prompt+ will do 168 | # nothing except tell the logger you generated the prompt. 169 | def generate_prompt(prompt) # :nodoc: 170 | ActiveSupport::Notifications.instrument("deliver.active_agent") do |payload| 171 | set_payload_for_prompt(payload, prompt) 172 | yield # Let Prompt do the generation actions 173 | end 174 | end 175 | 176 | private 177 | 178 | def set_payload_for_prompt(payload, prompt) 179 | payload[:prompt] = prompt.encoded 180 | payload[:agent] = agent_name 181 | payload[:message_id] = prompt.message_id 182 | payload[:date] = prompt.date 183 | payload[:perform_generations] = prompt.perform_generations 184 | end 185 | 186 | def method_missing(method_name, ...) 187 | if action_methods.include?(method_name.name) 188 | Generation.new(self, method_name, ...) 189 | else 190 | super 191 | end 192 | end 193 | 194 | def respond_to_missing?(method, include_all = false) 195 | action_methods.include?(method.name) || super 196 | end 197 | end 198 | 199 | attr_internal :prompt_context 200 | 201 | def agent_stream 202 | proc do |message, delta, stop| 203 | run_stream_callbacks(message, delta, stop) do |message, delta, stop| 204 | yield message, delta, stop if block_given? 205 | end 206 | end 207 | end 208 | 209 | def embed 210 | prompt_context.options.merge(options) 211 | generation_provider.embed(prompt_context) if prompt_context && generation_provider 212 | handle_response(generation_provider.response) 213 | end 214 | 215 | # Add embedding capability to Message class 216 | ActiveAgent::ActionPrompt::Message.class_eval do 217 | def embed 218 | agent_class = ActiveAgent::Base.descendants.first 219 | agent = agent_class.new 220 | agent.prompt_context = ActiveAgent::ActionPrompt::Prompt.new(message: self) 221 | agent.embed 222 | self 223 | end 224 | end 225 | 226 | # Make prompt_context accessible for chaining 227 | # attr_accessor :prompt_context 228 | 229 | def perform_generation 230 | prompt_context.options.merge(options) 231 | generation_provider.generate(prompt_context) if prompt_context && generation_provider 232 | handle_response(generation_provider.response) 233 | end 234 | 235 | def handle_response(response) 236 | return response unless response.message.requested_actions.present? 237 | perform_actions(requested_actions: response.message.requested_actions) 238 | update_prompt_context(response) 239 | end 240 | 241 | def update_prompt_context(response) 242 | prompt_context.message = prompt_context.messages.last 243 | ActiveAgent::GenerationProvider::Response.new(prompt: prompt_context) 244 | end 245 | 246 | def perform_actions(requested_actions:) 247 | requested_actions.each do |action| 248 | perform_action(action) 249 | end 250 | end 251 | 252 | def perform_action(action) 253 | current_context = prompt_context.clone 254 | process(action.name, *action.params) 255 | prompt_context.messages.last.role = :tool 256 | prompt_context.messages.last.action_id = action.id 257 | current_context.messages << prompt_context.messages.last 258 | self.prompt_context = current_context 259 | end 260 | 261 | def initialize 262 | super 263 | @_prompt_was_called = false 264 | @_prompt_context = ActiveAgent::ActionPrompt::Prompt.new(instructions: options[:instructions], options: options) 265 | end 266 | 267 | def process(method_name, *args) # :nodoc: 268 | payload = { 269 | agent: self.class.name, 270 | action: method_name, 271 | args: args 272 | } 273 | 274 | ActiveSupport::Notifications.instrument("process.active_agent", payload) do 275 | super 276 | @_prompt_context = ActiveAgent::ActionPrompt::Prompt.new unless @_prompt_was_called 277 | end 278 | end 279 | ruby2_keywords(:process) 280 | 281 | class NullPrompt # :nodoc: 282 | def message 283 | "" 284 | end 285 | 286 | def header 287 | {} 288 | end 289 | 290 | def respond_to?(string, include_all = false) 291 | true 292 | end 293 | 294 | def method_missing(...) 295 | nil 296 | end 297 | end 298 | 299 | # Returns the name of the agent object. 300 | def agent_name 301 | self.class.agent_name 302 | end 303 | 304 | def headers(args = nil) 305 | if args 306 | @_prompt_context.headers(args) 307 | else 308 | @_prompt_context 309 | end 310 | end 311 | 312 | def prompt_with(*) 313 | prompt_context.update_prompt_context(*) 314 | end 315 | 316 | def prompt(headers = {}, &block) 317 | return prompt_context if @_prompt_was_called && headers.blank? && !block 318 | content_type = headers[:content_type] 319 | headers = apply_defaults(headers) 320 | prompt_context.messages = headers[:messages] || [] 321 | prompt_context.context_id = headers[:context_id] 322 | 323 | prompt_context.charset = charset = headers[:charset] 324 | 325 | responses = collect_responses(headers, &block) 326 | 327 | @_prompt_was_called = true 328 | 329 | create_parts_from_responses(prompt_context, responses) 330 | 331 | prompt_context.content_type = set_content_type(prompt_context, content_type, headers[:content_type]) 332 | prompt_context.charset = charset 333 | prompt_context.actions = headers[:actions] || action_schemas 334 | 335 | prompt_context 336 | end 337 | 338 | def action_schemas 339 | action_methods.map do |action| 340 | if action != "text_prompt" 341 | JSON.parse render_to_string(locals: { action_name: action }, action: action, formats: :json) 342 | end 343 | end.compact 344 | end 345 | 346 | private 347 | 348 | def set_content_type(m, user_content_type, class_default) # :doc: 349 | if user_content_type.present? 350 | user_content_type 351 | else 352 | prompt_context.content_type || class_default 353 | end 354 | end 355 | 356 | # Translates the +subject+ using \Rails I18n class under [agent_scope, action_name] scope. 357 | # If it does not find a translation for the +subject+ under the specified scope it will default to a 358 | # humanized version of the action_name. 359 | # If the subject has interpolations, you can pass them through the +interpolations+ parameter. 360 | def default_i18n_subject(interpolations = {}) # :doc: 361 | agent_scope = self.class.agent_name.tr("/", ".") 362 | I18n.t(:subject, **interpolations.merge(scope: [ agent_scope, action_name ], default: action_name.humanize)) 363 | end 364 | 365 | def apply_defaults(headers) 366 | default_values = self.class.default.except(*headers.keys).transform_values do |value| 367 | compute_default(value) 368 | end 369 | 370 | headers.reverse_merge(default_values) 371 | end 372 | 373 | def compute_default(value) 374 | return value unless value.is_a?(Proc) 375 | 376 | if value.arity == 1 377 | instance_exec(self, &value) 378 | else 379 | instance_exec(&value) 380 | end 381 | end 382 | 383 | def assign_headers_to_prompt_context(prompt_context, headers) 384 | assignable = headers.except(:parts_order, :content_type, :body, :template_name, 385 | :template_path, :delivery_method, :delivery_method_options) 386 | assignable.each { |k, v| prompt_context[k] = v } 387 | end 388 | 389 | def collect_responses(headers, &) 390 | if block_given? 391 | collect_responses_from_block(headers, &) 392 | elsif headers[:body] 393 | collect_responses_from_text(headers) 394 | else 395 | collect_responses_from_templates(headers) 396 | end 397 | end 398 | 399 | def collect_responses_from_block(headers) 400 | templates_name = headers[:template_name] || action_name 401 | collector = Collector.new(lookup_context) { render(templates_name) } 402 | yield(collector) 403 | collector.responses 404 | end 405 | 406 | def collect_responses_from_text(headers) 407 | [ { 408 | body: headers.delete(:body), 409 | content_type: headers[:content_type] || "text/plain" 410 | } ] 411 | end 412 | 413 | def collect_responses_from_templates(headers) 414 | templates_path = headers[:template_path] || self.class.agent_name 415 | templates_name = headers[:template_name] || action_name 416 | 417 | each_template(Array(templates_path), templates_name).map do |template| 418 | next if template.format == :json 419 | 420 | format = template.format || formats.first 421 | { 422 | body: render(template: template, formats: [ format ]), 423 | content_type: Mime[format].to_s 424 | } 425 | end.compact 426 | end 427 | 428 | def each_template(paths, name, &) 429 | templates = lookup_context.find_all(name, paths) 430 | if templates.empty? 431 | raise ActionView::MissingTemplate.new(paths, name, paths, false, "agent") 432 | else 433 | templates.uniq(&:format).each(&) 434 | end 435 | end 436 | 437 | def create_parts_from_responses(prompt_context, responses) 438 | if responses.size > 1 439 | # prompt_container = ActiveAgent::ActionPrompt::Prompt.new 440 | # prompt_container.content_type = "multipart/alternative" 441 | responses.each { |r| insert_part(prompt_context, r, prompt_context.charset) } 442 | # prompt_context.add_part(prompt_container) 443 | else 444 | responses.each { |r| insert_part(prompt_context, r, prompt_context.charset) } 445 | end 446 | end 447 | 448 | def insert_part(prompt_context, response, charset) 449 | message = ActiveAgent::ActionPrompt::Message.new( 450 | content: response[:body], 451 | content_type: response[:content_type], 452 | charset: charset 453 | ) 454 | prompt_context.add_part(message) 455 | end 456 | 457 | # This and #instrument_name is for caching instrument 458 | def instrument_payload(key) 459 | { 460 | agent: agent_name, 461 | key: key 462 | } 463 | end 464 | 465 | def instrument_name 466 | "active_agent" 467 | end 468 | 469 | def _protected_ivars 470 | PROTECTED_IVARS 471 | end 472 | 473 | ActiveSupport.run_load_hooks(:active_agent, self) 474 | end 475 | end 476 | -------------------------------------------------------------------------------- /lib/active_agent/callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent 4 | module Callbacks 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | include ActiveSupport::Callbacks 9 | define_callbacks :generate, skip_after_callbacks_if_terminated: true 10 | define_callbacks :stream, skip_after_callbacks_if_terminated: true 11 | end 12 | 13 | module ClassMethods 14 | # Defines a callback that will get called right before the 15 | # prompt is sent to the generation provider method. 16 | def before_generate(*filters, &) 17 | set_callback(:generate, :before, *filters, &) 18 | end 19 | 20 | # Defines a callback that will get called right after the 21 | # prompt's generation method is finished. 22 | def after_generate(*filters, &) 23 | set_callback(:generate, :after, *filters, &) 24 | end 25 | 26 | # Defines a callback that will get called around the prompt's generation method. 27 | def around_generate(*filters, &) 28 | set_callback(:generate, :around, *filters, &) 29 | end 30 | 31 | # Defines a callback for handling streaming responses during generation 32 | def on_stream(*filters, &) 33 | set_callback(:stream, :before, *filters, &) 34 | end 35 | end 36 | 37 | # Helper method to run stream callbacks 38 | def run_stream_callbacks(message, delta = nil, stop = false) 39 | run_callbacks(:stream) do 40 | yield(message, delta, stop) if block_given? 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/active_agent/collector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "abstract_controller/collector" 4 | require "active_support/core_ext/hash/reverse_merge" 5 | require "active_support/core_ext/array/extract_options" 6 | 7 | module ActiveAgent 8 | class Collector 9 | include AbstractController::Collector 10 | attr_reader :responses 11 | 12 | def initialize(context, &block) 13 | @context = context 14 | @responses = [] 15 | @default_render = block 16 | end 17 | 18 | def any(*args, &block) 19 | options = args.extract_options! 20 | raise ArgumentError, "You have to supply at least one format" if args.empty? 21 | args.each { |type| send(type, options.dup, &block) } 22 | end 23 | alias_method :all, :any 24 | 25 | def custom(mime, options = {}) 26 | options.reverse_merge!(content_type: mime.to_s) 27 | @context.formats = [ mime.to_sym ] 28 | options[:body] = block_given? ? yield : @default_render.call 29 | @responses << options 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/active_agent/deprecator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent 4 | def self.deprecator # :nodoc: 5 | @deprecator ||= ActiveSupport::Deprecation.new 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/active_agent/generation.rb: -------------------------------------------------------------------------------- 1 | # lib/active_agent/generation.rb 2 | require "delegate" 3 | 4 | module ActiveAgent 5 | class Generation < Delegator 6 | def initialize(agent_class, action, *args) 7 | @agent_class, @action, @args = agent_class, action, args 8 | @processed_agent = nil 9 | @prompt_context = nil 10 | end 11 | ruby2_keywords(:initialize) 12 | 13 | def __getobj__ 14 | @prompt_context ||= processed_agent.prompt_context 15 | end 16 | 17 | def __setobj__(prompt_context) 18 | @prompt_context = prompt_context 19 | end 20 | 21 | def prompt_context 22 | __getobj__ 23 | end 24 | 25 | def processed? 26 | @processed_agent || @prompt_context 27 | end 28 | 29 | def generate_later!(options = {}) 30 | enqueue_generation :generate_now!, options 31 | end 32 | 33 | def generate_later(options = {}) 34 | enqueue_generation :generate_now, options 35 | end 36 | 37 | def generate_now! 38 | processed_agent.handle_exceptions do 39 | processed_agent.run_callbacks(:generate) do 40 | processed_agent.perform_generation! 41 | end 42 | end 43 | end 44 | 45 | def generate_now 46 | processed_agent.handle_exceptions do 47 | processed_agent.run_callbacks(:generate) do 48 | processed_agent.perform_generation 49 | end 50 | end 51 | end 52 | 53 | private 54 | 55 | def processed_agent 56 | @processed_agent ||= @agent_class.new.tap do |agent| 57 | agent.process(@action, *@args) 58 | end 59 | end 60 | 61 | def enqueue_generation(generation_method, options = {}) 62 | if processed? 63 | ::Kernel.raise "You've accessed the context before asking to " \ 64 | "generate it later, so you may have made local changes that would " \ 65 | "be silently lost if we enqueued a job to generate it. Why? Only " \ 66 | "the agent method *arguments* are passed with the generation job! " \ 67 | "Do not access the context in any way if you mean to generate it " \ 68 | "later. Workarounds: 1. don't touch the context before calling " \ 69 | "#generate_later, 2. only touch the context *within your agent " \ 70 | "method*, or 3. use a custom Active Job instead of #generate_later." 71 | else 72 | @agent_class.generation_job.set(options).perform_later( 73 | @agent_class.name, @action.to_s, args: @args 74 | ) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/active_agent/generation_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_job" 4 | 5 | module ActiveAgent 6 | # = Active Agent \GenerationJob 7 | # 8 | # The +ActiveAgent::GenerationJob+ class is used when you 9 | # want to generate content outside of the request-response cycle. It supports 10 | # sending messages with parameters. 11 | # 12 | # Exceptions are rescued and handled by the agent class. 13 | class GenerationJob < ActiveJob::Base # :nodoc: 14 | queue_as do 15 | agent_class = arguments.first.constantize 16 | agent_class.generate_later_queue_name 17 | end 18 | 19 | rescue_from StandardError, with: :handle_exception_with_agent_class 20 | 21 | def perform(agent_class_name, action_name, args:, params: nil) 22 | agent_class = agent_class_name.constantize 23 | agent = agent_class.new 24 | agent.params = params if params 25 | agent.process(action_name, *args) 26 | agent.perform_generation 27 | end 28 | 29 | private 30 | 31 | # "Deserialize" the agent class name by hand in case another argument 32 | # (like a Global ID reference) raised DeserializationError. 33 | def agent_class 34 | if agent = Array(@serialized_arguments).first || Array(arguments).first 35 | agent.constantize 36 | end 37 | end 38 | 39 | def handle_exception_with_agent_class(exception) 40 | if klass = agent_class 41 | klass.handle_exception exception 42 | else 43 | raise exception 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/active_agent/generation_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tmpdir" 4 | require_relative "action_prompt" 5 | 6 | module ActiveAgent 7 | # = Active Agent \GenerationM74ethods 8 | # 9 | # This module handles everything related to prompt generation, from registering 10 | # new generation methods to configuring the prompt object to be sent. 11 | module GenerationMethods 12 | extend ActiveSupport::Concern 13 | 14 | included do 15 | # Do not make this inheritable, because we always want it to propagate 16 | cattr_accessor :raise_generation_errors, default: true 17 | cattr_accessor :perform_generations, default: true 18 | 19 | class_attribute :generation_methods, default: {}.freeze 20 | class_attribute :generation_method, default: :smtp 21 | 22 | add_generation_method :test, ActiveAgent::ActionPrompt::TestAgent 23 | end 24 | 25 | module ClassMethods 26 | delegate :generations, :generations=, to: ActiveAgent::ActionPrompt::TestAgent 27 | 28 | def add_generation_method(symbol, klass, default_options = {}) 29 | class_attribute(:"#{symbol}_settings") unless respond_to?(:"#{symbol}_settings") 30 | public_send(:"#{symbol}_settings=", default_options) 31 | self.generation_methods = generation_methods.merge(symbol.to_sym => klass).freeze 32 | end 33 | 34 | def wrap_generation_behavior(prompt, method = nil, options = nil) # :nodoc: 35 | method ||= generation_method 36 | prompt.generation_handler = self 37 | 38 | case method 39 | when NilClass 40 | raise "Generation method cannot be nil" 41 | when Symbol 42 | if klass = generation_methods[method] 43 | prompt.generation_method(klass, (send(:"#{method}_settings") || {}).merge(options || {})) 44 | else 45 | raise "Invalid generation method #{method.inspect}" 46 | end 47 | else 48 | prompt.generation_method(method) 49 | end 50 | 51 | prompt.perform_generations = perform_generations 52 | prompt.raise_generation_errors = raise_generation_errors 53 | end 54 | end 55 | 56 | def wrap_generation_behavior!(*) # :nodoc: 57 | self.class.wrap_generation_behavior(prompt, *) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/active_agent/generation_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent 4 | module GenerationProvider 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | class_attribute :_generation_provider_name, instance_accessor: false, instance_predicate: false 9 | class_attribute :_generation_provider, instance_accessor: false, instance_predicate: false 10 | 11 | delegate :generation_provider, to: :class 12 | end 13 | 14 | module ClassMethods 15 | def configuration(provider_name, **options) 16 | config = ActiveAgent.config[provider_name.to_s] || ActiveAgent.config.dig(ENV["RAILS_ENV"], provider_name.to_s) 17 | 18 | raise "Configuration not found for provider: #{provider_name}" unless config 19 | config.merge!(options) 20 | configure_provider(config) 21 | end 22 | 23 | def configure_provider(config) 24 | require "active_agent/generation_provider/#{config["service"].underscore}_provider" 25 | ActiveAgent::GenerationProvider.const_get("#{config["service"].camelize}Provider").new(config) 26 | rescue LoadError 27 | raise "Missing generation provider configuration for #{config["service"].inspect}" 28 | end 29 | 30 | def generation_provider 31 | self.generation_provider = :openai if _generation_provider.nil? 32 | _generation_provider 33 | end 34 | 35 | def generation_provider_name 36 | self.generation_provider = :openai if _generation_provider_name.nil? 37 | _generation_provider_name 38 | end 39 | 40 | def generation_provider=(name_or_provider) 41 | case name_or_provider 42 | when Symbol, String 43 | provider = configuration(name_or_provider) 44 | assign_provider(name_or_provider.to_s, provider) 45 | else 46 | raise ArgumentError 47 | end 48 | end 49 | 50 | private 51 | 52 | def assign_provider(provider_name, generation_provider) 53 | self._generation_provider_name = provider_name 54 | self._generation_provider = generation_provider 55 | end 56 | end 57 | 58 | def generation_provider 59 | self.class.generation_provider 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/active_agent/generation_provider/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/lib/active_agent/generation_provider/README.md -------------------------------------------------------------------------------- /lib/active_agent/generation_provider/anthropic_provider.rb: -------------------------------------------------------------------------------- 1 | # lib/active_agent/generation_provider/anthropic_provider.rb 2 | 3 | require "anthropic" 4 | require "active_agent/action_prompt/action" 5 | require_relative "base" 6 | require_relative "response" 7 | 8 | module ActiveAgent 9 | module GenerationProvider 10 | class AnthropicProvider < Base 11 | def initialize(config) 12 | super 13 | @api_key = config["api_key"] 14 | @model_name = config["model"] || "claude-3-5-sonnet-20240620" 15 | @client = Anthropic::Client.new(access_token: @api_key) 16 | end 17 | 18 | def generate(prompt) 19 | @prompt = prompt 20 | 21 | chat_prompt(parameters: prompt_parameters) 22 | rescue => e 23 | raise GenerationProviderError, e.message 24 | end 25 | 26 | def chat_prompt(parameters: prompt_parameters) 27 | parameters[:stream] = provider_stream if prompt.options[:stream] || config["stream"] 28 | 29 | chat_response(@client.messages(parameters)) 30 | end 31 | 32 | private 33 | 34 | def provider_stream 35 | agent_stream = prompt.options[:stream] 36 | message = ActiveAgent::ActionPrompt::Message.new(content: "", role: :assistant) 37 | @response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt, message:) 38 | 39 | proc do |chunk| 40 | if new_content = chunk.dig(:delta, :text) 41 | message.content += new_content 42 | agent_stream.call(message) if agent_stream.respond_to?(:call) 43 | end 44 | end 45 | end 46 | 47 | def prompt_parameters(model: @prompt.options[:model] || @model_name, messages: @prompt.messages, temperature: @config["temperature"] || 0.7, tools: @prompt.actions) 48 | params = { 49 | model: model, 50 | messages: provider_messages(messages), 51 | temperature: temperature, 52 | max_tokens: 4096 53 | } 54 | 55 | if tools&.present? 56 | params[:tools] = format_tools(tools) 57 | end 58 | 59 | params 60 | end 61 | 62 | def format_tools(tools) 63 | tools.map do |tool| 64 | { 65 | name: tool[:name] || tool[:function][:name], 66 | description: tool[:description], 67 | input_schema: tool[:parameters] 68 | } 69 | end 70 | end 71 | 72 | def provider_messages(messages) 73 | messages.map do |message| 74 | provider_message = { 75 | role: convert_role(message.role), 76 | content: [] 77 | } 78 | 79 | provider_message[:content] << if message.content_type == "image_url" 80 | { 81 | type: "image", 82 | source: { 83 | type: "url", 84 | url: message.content 85 | } 86 | } 87 | else 88 | { 89 | type: "text", 90 | text: message.content 91 | } 92 | end 93 | 94 | provider_message 95 | end 96 | end 97 | 98 | def convert_role(role) 99 | case role.to_s 100 | when "system" then "system" 101 | when "user" then "user" 102 | when "assistant" then "assistant" 103 | when "tool", "function" then "assistant" 104 | else "user" 105 | end 106 | end 107 | 108 | def chat_response(response) 109 | return @response if prompt.options[:stream] 110 | 111 | content = response.content.first[:text] 112 | 113 | message = ActiveAgent::ActionPrompt::Message.new( 114 | content: content, 115 | role: "assistant", 116 | action_requested: response.stop_reason == "tool_use", 117 | requested_actions: handle_actions(response.tool_use) 118 | ) 119 | 120 | update_context(prompt: prompt, message: message, response: response) 121 | 122 | @response = ActiveAgent::GenerationProvider::Response.new( 123 | prompt: prompt, 124 | message: message, 125 | raw_response: response 126 | ) 127 | end 128 | 129 | def handle_actions(tool_uses) 130 | return unless tool_uses&.present? 131 | 132 | tool_uses.map do |tool_use| 133 | ActiveAgent::ActionPrompt::Action.new( 134 | id: tool_use[:id], 135 | name: tool_use[:name], 136 | params: tool_use[:input] 137 | ) 138 | end 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/active_agent/generation_provider/base.rb: -------------------------------------------------------------------------------- 1 | # lib/active_agent/generation_provider/base.rb 2 | 3 | module ActiveAgent 4 | module GenerationProvider 5 | class Base 6 | class GenerationProviderError < StandardError; end 7 | attr_reader :client, :config, :prompt, :response 8 | 9 | def initialize(config) 10 | @config = config 11 | @prompt = nil 12 | @response = nil 13 | end 14 | 15 | def generate(prompt) 16 | raise NotImplementedError, "Subclasses must implement the 'generate' method" 17 | end 18 | 19 | private 20 | 21 | def handle_response(response) 22 | @response = ActiveAgent::GenerationProvider::Response.new(message:, raw_response: response) 23 | raise NotImplementedError, "Subclasses must implement the 'handle_response' method" 24 | end 25 | 26 | def update_context(prompt:, message:, response:) 27 | prompt.message = message 28 | prompt.messages << message 29 | end 30 | 31 | protected 32 | 33 | def prompt_parameters 34 | { 35 | messages: @prompt.messages, 36 | temperature: @config["temperature"] || 0.7 37 | } 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/active_agent/generation_provider/ollama_provider.rb: -------------------------------------------------------------------------------- 1 | require "openai" 2 | require_relative "open_ai_provider" 3 | 4 | module ActiveAgent 5 | module GenerationProvider 6 | class OllamaProvider < OpenAIProvider 7 | def initialize(config) 8 | @config = config 9 | @api_key = config["api_key"] 10 | @model_name = config["model"] 11 | @host = config["host"] || "http://localhost:11434" 12 | @client = OpenAI::Client.new(uri_base: @host, access_token: @api_key, log_errors: true) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/active_agent/generation_provider/open_ai_provider.rb: -------------------------------------------------------------------------------- 1 | require "openai" 2 | require "active_agent/action_prompt/action" 3 | require_relative "base" 4 | require_relative "response" 5 | 6 | module ActiveAgent 7 | module GenerationProvider 8 | class OpenAIProvider < Base 9 | def initialize(config) 10 | super 11 | @api_key = config["api_key"] 12 | @model_name = config["model"] || "gpt-4o-mini" 13 | @client = OpenAI::Client.new(access_token: @api_key, log_errors: true) 14 | end 15 | 16 | def generate(prompt) 17 | @prompt = prompt 18 | 19 | chat_prompt(parameters: prompt_parameters) 20 | rescue => e 21 | raise GenerationProviderError, e.message 22 | end 23 | 24 | def embed(prompt) 25 | @prompt = prompt 26 | 27 | embeddings_prompt(parameters: embeddings_parameters) 28 | rescue => e 29 | raise GenerationProviderError, e.message 30 | end 31 | 32 | private 33 | 34 | def provider_stream 35 | agent_stream = prompt.options[:stream] 36 | message = ActiveAgent::ActionPrompt::Message.new(content: "", role: :assistant) 37 | 38 | @response = ActiveAgent::GenerationProvider::Response.new(prompt:, message:) 39 | proc do |chunk, bytesize| 40 | new_content = chunk.dig("choices", 0, "delta", "content") 41 | if new_content && !new_content.blank 42 | message.content += new_content 43 | 44 | agent_stream.call(message, new_content, false) do |message, new_content| 45 | yield message, new_content if block_given? 46 | end 47 | elsif chunk.dig("choices", 0, "delta", "tool_calls") && !chunk.dig("choices", 0, "delta", "tool_calls").empty? 48 | message = handle_message(chunk.dig("choices", 0, "delta")) 49 | prompt.messages << message 50 | @response = ActiveAgent::GenerationProvider::Response.new(prompt:, message:) 51 | end 52 | 53 | agent_stream.call(message, nil, true) do |message| 54 | yield message, nil if block_given? 55 | end 56 | end 57 | end 58 | 59 | def prompt_parameters(model: @prompt.options[:model] || @model_name, messages: @prompt.messages, temperature: @config["temperature"] || 0.7, tools: @prompt.actions) 60 | { 61 | model: model, 62 | messages: provider_messages(messages), 63 | temperature: temperature, 64 | tools: tools.presence 65 | } 66 | end 67 | 68 | def provider_messages(messages) 69 | messages.map do |message| 70 | provider_message = { 71 | role: message.role, 72 | tool_call_id: message.action_id.presence, 73 | content: message.content, 74 | type: message.content_type, 75 | charset: message.charset 76 | }.compact 77 | 78 | if message.content_type == "image_url" 79 | provider_message[:image_url] = { url: message.content } 80 | end 81 | provider_message 82 | end 83 | end 84 | 85 | def chat_response(response) 86 | return @response if prompt.options[:stream] 87 | 88 | message_json = response.dig("choices", 0, "message") 89 | 90 | message = handle_message(message_json) 91 | 92 | update_context(prompt: prompt, message: message, response: response) 93 | 94 | @response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt, message: message, raw_response: response) 95 | end 96 | 97 | def handle_message(message_json) 98 | ActiveAgent::ActionPrompt::Message.new( 99 | content: message_json["content"], 100 | role: message_json["role"].intern, 101 | action_requested: message_json["finish_reason"] == "tool_calls", 102 | requested_actions: handle_actions(message_json["tool_calls"]) 103 | ) 104 | end 105 | 106 | def handle_actions(tool_calls) 107 | return [] if tool_calls.nil? || tool_calls.empty? 108 | 109 | tool_calls.map do |tool_call| 110 | next if tool_call["function"].nil? || tool_call["function"]["name"].blank? 111 | args = tool_call["function"]["arguments"].blank? ? nil : JSON.parse(tool_call["function"]["arguments"], { symbolize_names: true }) 112 | 113 | ActiveAgent::ActionPrompt::Action.new( 114 | id: tool_call["id"], 115 | name: tool_call.dig("function", "name"), 116 | params: args 117 | ) 118 | end.compact 119 | end 120 | 121 | def chat_prompt(parameters: prompt_parameters) 122 | parameters[:stream] = provider_stream if prompt.options[:stream] || config["stream"] 123 | chat_response(@client.chat(parameters: parameters)) 124 | end 125 | 126 | def embeddings_parameters(input: prompt.message.content, model: "text-embedding-3-large") 127 | { 128 | model: model, 129 | input: input 130 | } 131 | end 132 | 133 | def embeddings_response(response) 134 | message = ActiveAgent::ActionPrompt::Message.new(content: response.dig("data", 0, "embedding"), role: "assistant") 135 | 136 | @response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt, message: message, raw_response: response) 137 | end 138 | 139 | def embeddings_prompt(parameters:) 140 | embeddings_response(@client.embeddings(parameters: embeddings_parameters)) 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/active_agent/generation_provider/open_router_provider.rb: -------------------------------------------------------------------------------- 1 | require "openai" 2 | require_relative "open_ai_provider" 3 | 4 | module ActiveAgent 5 | module GenerationProvider 6 | class OpenRouterProvider < OpenAIProvider 7 | def initialize(config) 8 | @config = config 9 | @api_key = config["api_key"] 10 | @model_name = config["model"] 11 | @client = OpenAI::Client.new(uri_base: "https://openrouter.ai/api/v1", access_token: @api_key, log_errors: true) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/active_agent/generation_provider/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent 4 | module GenerationProvider 5 | class Response 6 | attr_reader :message, :prompt, :raw_response 7 | 8 | def initialize(prompt:, message: nil, raw_response: nil) 9 | @prompt = prompt 10 | @message = message || prompt.message 11 | @raw_response = raw_response 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/active_agent/inline_preview_interceptor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "base64" 4 | 5 | module ActiveAgent 6 | # = Active Agent \InlinePreviewInterceptor 7 | # 8 | # Implements a agent preview interceptor that converts image tag src attributes 9 | # that use inline cid: style URLs to data: style URLs so that they are visible 10 | # when previewing an HTML prompt in a web browser. 11 | # 12 | # This interceptor is enabled by default. To disable it, delete it from the 13 | # ActiveAgent::Base.preview_interceptors array: 14 | # 15 | # ActiveAgent::Base.preview_interceptors.delete(ActiveAgent::InlinePreviewInterceptor) 16 | # 17 | class InlinePreviewInterceptor 18 | PATTERN = /src=(?:"cid:[^"]+"|'cid:[^']+')/i 19 | 20 | include Base64 21 | 22 | def self.previewing_prompt(context) # :nodoc: 23 | new(context).transform! 24 | end 25 | 26 | def initialize(context) # :nodoc: 27 | @context = context 28 | end 29 | 30 | def transform! # :nodoc: 31 | return context if html_part.blank? 32 | 33 | html_part.body = html_part.decoded.gsub(PATTERN) do |match| 34 | if part = find_part(match[9..-2]) 35 | %(src="#{data_url(part)}") 36 | else 37 | match 38 | end 39 | end 40 | 41 | context 42 | end 43 | 44 | private 45 | 46 | attr_reader :context 47 | 48 | def html_part 49 | @html_part ||= context.html_part 50 | end 51 | 52 | def data_url(part) 53 | "data:#{part.mime_type};base64,#{strict_encode64(part.body.raw_source)}" 54 | end 55 | 56 | def find_part(cid) 57 | context.all_parts.find { |p| p.attachment? && p.cid == cid } 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/active_agent/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # # frozen_string_literal: true 2 | 3 | # require "active_support/log_subscriber" 4 | 5 | # module ActiveAgent 6 | # # = Active Agent \LogSubscriber 7 | # # 8 | # # Implements the ActiveSupport::LogSubscriber for logging notifications when 9 | # # prompt is generated. 10 | # class LogSubscriber < ActiveSupport::LogSubscriber 11 | # # A prompt was generated. 12 | # def deliver(event) 13 | # info do 14 | # if exception = event.payload[:exception_object] 15 | # "Failed delivery of prompt #{event.payload[:message_id]} error_class=#{exception.class} error_message=#{exception.message.inspect}" 16 | # elsif event.payload[:perform_deliveries] 17 | # "Generated response for prompt #{event.payload[:message_id]} (#{event.duration.round(1)}ms)" 18 | # else 19 | # "Skipped generation of prompt #{event.payload[:message_id]} as `perform_generation` is false" 20 | # end 21 | # end 22 | 23 | # debug { event.payload[:mail] } 24 | # end 25 | # subscribe_log_level :deliver, :debug 26 | 27 | # # An email was generated. 28 | # def process(event) 29 | # debug do 30 | # agent = event.payload[:agent] 31 | # action = event.payload[:action] 32 | # "#{agent}##{action}: processed outbound mail in #{event.duration.round(1)}ms" 33 | # end 34 | # end 35 | # subscribe_log_level :process, :debug 36 | 37 | # # Use the logger configured for ActionMailer::Base. 38 | # def logger 39 | # ActionMailer::Base.logger 40 | # end 41 | # end 42 | # end 43 | 44 | # ActionMailer::LogSubscriber.attach_to :action_mailer 45 | -------------------------------------------------------------------------------- /lib/active_agent/parameterized.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent 4 | module Parameterized 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | attr_writer :params 9 | 10 | def params 11 | @params ||= {} 12 | end 13 | end 14 | 15 | module ClassMethods 16 | def with(params) 17 | ActiveAgent::Parameterized::Agent.new(self, params) 18 | end 19 | end 20 | 21 | class Agent 22 | def initialize(agent, params) 23 | @agent = agent 24 | @params = params 25 | end 26 | 27 | def method_missing(method_name, ...) 28 | if @agent.public_instance_methods.include?(method_name) 29 | ActiveAgent::Parameterized::Generation.new(@agent, method_name, @params, ...) 30 | else 31 | super 32 | end 33 | end 34 | 35 | def respond_to_missing?(method, include_all = false) 36 | @agent.respond_to?(method, include_all) 37 | end 38 | end 39 | 40 | class Generation < ActiveAgent::Generation 41 | def initialize(agent_class, action, params, ...) 42 | super(agent_class, action, ...) 43 | @params = params 44 | end 45 | 46 | private 47 | 48 | def processed_agent 49 | @processed_agent ||= @agent_class.new.tap do |agent| 50 | agent.params = @params 51 | agent.process @action, *@args 52 | end 53 | end 54 | 55 | def enqueue_generation(generation_method, options = {}) 56 | if processed? 57 | super 58 | else 59 | @agent_class.generation_job.set(options).perform_later( 60 | @agent_class.name, @action.to_s, params: @params, args: @args 61 | ) 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/active_agent/preview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/descendants_tracker" 4 | 5 | module ActiveAgent 6 | module Previews # :nodoc: 7 | extend ActiveSupport::Concern 8 | 9 | included do 10 | mattr_accessor :preview_paths, instance_writer: false, default: [] 11 | 12 | mattr_accessor :show_previews, instance_writer: false 13 | 14 | mattr_accessor :preview_interceptors, instance_writer: false, default: [ ActiveAgent::InlinePreviewInterceptor ] 15 | end 16 | 17 | module ClassMethods 18 | # Register one or more Interceptors which will be called before prompt is previewed. 19 | def register_preview_interceptors(*interceptors) 20 | interceptors.flatten.compact.each { |interceptor| register_preview_interceptor(interceptor) } 21 | end 22 | 23 | # Unregister one or more previously registered Interceptors. 24 | def unregister_preview_interceptors(*interceptors) 25 | interceptors.flatten.compact.each { |interceptor| unregister_preview_interceptor(interceptor) } 26 | end 27 | 28 | # Register an Interceptor which will be called before prompt is previewed. 29 | # Either a class or a string can be passed in as the Interceptor. If a 30 | # string is passed in it will be constantized. 31 | def register_preview_interceptor(interceptor) 32 | preview_interceptor = interceptor_class_for(interceptor) 33 | 34 | unless preview_interceptors.include?(preview_interceptor) 35 | preview_interceptors << preview_interceptor 36 | end 37 | end 38 | 39 | # Unregister a previously registered Interceptor. 40 | # Either a class or a string can be passed in as the Interceptor. If a 41 | # string is passed in it will be constantized. 42 | def unregister_preview_interceptor(interceptor) 43 | preview_interceptors.delete(interceptor_class_for(interceptor)) 44 | end 45 | 46 | private 47 | 48 | def interceptor_class_for(interceptor) 49 | case interceptor 50 | when String, Symbol 51 | interceptor.to_s.camelize.constantize 52 | else 53 | interceptor 54 | end 55 | end 56 | end 57 | end 58 | 59 | class Preview 60 | extend ActiveSupport::DescendantsTracker 61 | 62 | attr_reader :params 63 | 64 | def initialize(params = {}) 65 | @params = params 66 | end 67 | 68 | class << self 69 | # Returns all agent preview classes. 70 | def all 71 | load_previews if descendants.empty? 72 | descendants.sort_by { |agent| agent.name.titleize } 73 | end 74 | 75 | # Returns the prompt object for the given context. The registered preview 76 | # interceptors will be informed so that they can transform the message 77 | # as they would if the mail was actually being delivered. 78 | def call(context, params = {}) 79 | preview = new(params) 80 | prompt = preview.public_send(context) 81 | inform_preview_interceptors(prompt) 82 | prompt 83 | end 84 | 85 | # Returns all of the available prompt previews. 86 | def prompts 87 | public_instance_methods(false).map(&:to_s).sort 88 | end 89 | 90 | # Returns +true+ if the prompt exists. 91 | def prompt_exists?(prompt) 92 | prompts.include?(prompt) 93 | end 94 | 95 | # Returns +true+ if the preview exists. 96 | def exists?(preview) 97 | all.any? { |p| p.preview_name == preview } 98 | end 99 | 100 | # Find a agent preview by its underscored class name. 101 | def find(preview) 102 | all.find { |p| p.preview_name == preview } 103 | end 104 | 105 | # Returns the underscored name of the agent preview without the suffix. 106 | def preview_name 107 | name.delete_suffix("Preview").underscore 108 | end 109 | 110 | private 111 | 112 | def load_previews 113 | preview_paths.each do |preview_path| 114 | Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require file } 115 | end 116 | end 117 | 118 | def preview_paths 119 | Base.preview_paths 120 | end 121 | 122 | def show_previews 123 | Base.show_previews 124 | end 125 | 126 | def inform_preview_interceptors(context) 127 | Base.preview_interceptors.each do |interceptor| 128 | interceptor.previewing_prompt(context) 129 | end 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/active_agent/prompt_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent 4 | # = Active Agent's Action Prompt \PromptHelper 5 | # 6 | # Provides helper methods for ActiveAgent::Base that can be used for easily 7 | # formatting prompts, accessing agent or prompt instances. 8 | module PromptHelper 9 | # Access the agent instance. 10 | def agent 11 | @_controller 12 | end 13 | 14 | # Access the prompt instance. 15 | def context 16 | @_context 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/active_agent/queued_generation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent 4 | module QueuedGeneration 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | class_attribute :generation_job, default: ::ActiveAgent::GenerationJob 9 | class_attribute :generate_later_queue_name, default: :agents 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/active_agent/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_job/railtie" 4 | require "active_agent" 5 | # require "active_agent/engine" 6 | require "rails" 7 | require "abstract_controller/railties/routes_helpers" 8 | 9 | module ActiveAgent 10 | class Railtie < Rails::Railtie # :nodoc: 11 | config.active_agent = ActiveSupport::OrderedOptions.new 12 | config.active_agent.preview_paths = [] 13 | config.eager_load_namespaces << ActiveAgent 14 | 15 | initializer "active_agent.deprecator", before: :load_environment_config do |app| 16 | app.deprecators[:active_agent] = ActiveAgent.deprecator 17 | end 18 | 19 | initializer "active_agent.logger" do 20 | ActiveSupport.on_load(:active_agent) { self.logger ||= Rails.logger } 21 | end 22 | 23 | initializer "active_agent.set_configs" do |app| 24 | paths = app.config.paths 25 | options = app.config.active_agent 26 | 27 | options.assets_dir ||= paths["public"].first 28 | options.javascripts_dir ||= paths["public/javascripts"].first 29 | options.stylesheets_dir ||= paths["public/stylesheets"].first 30 | options.show_previews = Rails.env.development? if options.show_previews.nil? 31 | options.cache_store ||= Rails.cache 32 | options.preview_paths |= [ "#{Rails.root}/test/agents/previews" ] 33 | 34 | # make sure readers methods get compiled 35 | options.asset_host ||= app.config.asset_host 36 | options.relative_url_root ||= app.config.relative_url_root 37 | 38 | ActiveAgent.load_configuration(Rails.root.join("config", "active_agent.yml")) 39 | 40 | ActiveSupport.on_load(:active_agent) do 41 | include AbstractController::UrlFor 42 | extend ::AbstractController::Railties::RoutesHelpers.with(app.routes, false) 43 | include app.routes.mounted_helpers 44 | 45 | register_interceptors(options.delete(:interceptors)) 46 | register_preview_interceptors(options.delete(:preview_interceptors)) 47 | register_observers(options.delete(:observers)) 48 | self.view_paths = [ "#{Rails.root}/app/views" ] 49 | self.preview_paths |= options[:preview_paths] 50 | 51 | if (generation_job = options.delete(:generation_job)) 52 | self.generation_job = generation_job.constantize 53 | end 54 | 55 | options.each { |k, v| send(:"#{k}=", v) } 56 | end 57 | 58 | ActiveSupport.on_load(:action_dispatch_integration_test) do 59 | # include ActiveAgent::TestHelper 60 | # include ActiveAgent::TestCase::ClearTestDeliveries 61 | end 62 | end 63 | 64 | initializer "active_agent.set_autoload_paths", before: :set_autoload_paths do |app| 65 | # options = app.config.active_agent 66 | # app.config.paths["test/agents/previews"].concat(options.preview_paths) 67 | end 68 | 69 | initializer "active_agent.compile_config_methods" do 70 | ActiveSupport.on_load(:active_agent) do 71 | config.compile_methods! if config.respond_to?(:compile_methods!) 72 | end 73 | end 74 | 75 | config.after_initialize do |app| 76 | options = app.config.active_agent 77 | 78 | if options.show_previews 79 | app.routes.prepend do 80 | get "/rails/agents" => "rails/agents#index", :internal => true 81 | get "/rails/agents/download/*path" => "rails/agents#download", :internal => true 82 | get "/rails/agents/*path" => "rails/agents#preview", :internal => true 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/active_agent/rescuable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent # :nodoc: 4 | # = Active Agent \Rescuable 5 | # 6 | # Provides 7 | # {rescue_from}[rdoc-ref:ActiveSupport::Rescuable::ClassMethods#rescue_from] 8 | # for agents. Wraps agent action processing, generation job processing, and prompt 9 | # generation to handle configured errors. 10 | module Rescuable 11 | extend ActiveSupport::Concern 12 | include ActiveSupport::Rescuable 13 | 14 | class_methods do 15 | def handle_exception(exception) # :nodoc: 16 | rescue_with_handler(exception) || raise(exception) 17 | end 18 | end 19 | 20 | def handle_exceptions # :nodoc: 21 | yield 22 | rescue => exception 23 | rescue_with_handler(exception) || raise 24 | end 25 | 26 | private 27 | 28 | def process(...) 29 | handle_exceptions do 30 | super 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/active_agent/service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent 4 | class Service 5 | extend ActiveSupport::Autoload 6 | autoload :Configurator 7 | attr_accessor :name 8 | 9 | class << self 10 | def configure(service_name, configurations) 11 | Configurator.build(service_name, configurations) 12 | end 13 | 14 | def build(configurator:, name:, service: nil, **service_config) # :nodoc: 15 | new(**service_config).tap do |service_instance| 16 | service_instance.name = name 17 | end 18 | end 19 | end 20 | 21 | def generate(...) 22 | raise NotImplementedError 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/active_agent/test_case.rb: -------------------------------------------------------------------------------- 1 | # # frozen_string_literal: true 2 | 3 | # require_relative "test_helper" 4 | # require "active_support/test_case" 5 | # require "rails-dom-testing" 6 | 7 | # module ActiveAgent 8 | # class NonInferrableAgentError < ::StandardError 9 | # def initialize(name) 10 | # super("Unable to determine the agent to test from #{name}. " \ 11 | # "You'll need to specify it using tests YourAgent in your " \ 12 | # "test case definition") 13 | # end 14 | # end 15 | 16 | # class TestCase < ActiveSupport::TestCase 17 | # module ClearTestDeliveries 18 | # extend ActiveSupport::Concern 19 | 20 | # included do 21 | # setup :clear_test_generations 22 | # teardown :clear_test_generations 23 | # end 24 | 25 | # private 26 | 27 | # def clear_test_generations 28 | # if ActiveAgent::Base.generation_method == :test 29 | # ActiveAgent::Base.generations.clear 30 | # end 31 | # end 32 | # end 33 | 34 | # module Behavior 35 | # extend ActiveSupport::Concern 36 | 37 | # include ActiveSupport::Testing::ConstantLookup 38 | # include TestHelper 39 | # include Rails::Dom::Testing::Assertions::SelectorAssertions 40 | # include Rails::Dom::Testing::Assertions::DomAssertions 41 | 42 | # included do 43 | # class_attribute :_agent_class 44 | # setup :initialize_test_generations 45 | # setup :set_expected_prompt 46 | # teardown :restore_test_generations 47 | # ActiveSupport.run_load_hooks(:active_agent_test_case, self) 48 | # end 49 | 50 | # module ClassMethods 51 | # def tests(agent) 52 | # case agent 53 | # when String, Symbol 54 | # self._agent_class = agent.to_s.camelize.constantize 55 | # when Module 56 | # self._agent_class = agent 57 | # else 58 | # raise NonInferrableAgentError.new(agent) 59 | # end 60 | # end 61 | 62 | # def agent_class 63 | # if agent = _agent_class 64 | # agent 65 | # else 66 | # tests determine_default_agent(name) 67 | # end 68 | # end 69 | 70 | # def determine_default_agent(name) 71 | # agent = determine_constant_from_test_name(name) do |constant| 72 | # Class === constant && constant < ActiveAgent::Base 73 | # end 74 | # raise NonInferrableAgentError.new(name) if agent.nil? 75 | # agent 76 | # end 77 | # end 78 | 79 | # # Reads the fixture file for the given agent. 80 | # # 81 | # # This is useful when testing agents by being able to write the body of 82 | # # an promt inside a fixture. See the testing guide for a concrete example: 83 | # # https://guides.rubyonrails.org/testing.html#revenge-of-the-fixtures 84 | # def read_fixture(action) 85 | # IO.readlines(File.join(Rails.root, "test", "fixtures", self.class.agent_class.name.underscore, action)) 86 | # end 87 | 88 | # private 89 | 90 | # def initialize_test_generations 91 | # set_generation_method :test 92 | # @old_perform_generations = ActiveAgent::Base.perform_generations 93 | # ActiveAgent::Base.perform_generations = true 94 | # ActiveAgent::Base.generations.clear 95 | # end 96 | 97 | # def restore_test_generations 98 | # restore_generation_method 99 | # ActiveAgent::Base.perform_generations = @old_perform_generations 100 | # end 101 | 102 | # def set_generation_method(method) 103 | # @old_generation_method = ActiveAgent::Base.generation_method 104 | # ActiveAgent::Base.generation_method = method 105 | # end 106 | 107 | # def restore_generation_method 108 | # ActiveAgent::Base.generations.clear 109 | # ActiveAgent::Base.generation_method = @old_generation_method 110 | # end 111 | 112 | # def set_expected_prompt 113 | # @expected = ActiveAgent::ActionPrompt::Prompt.new 114 | # @expected.content_type ["text", "plain", {"charset" => charset}] 115 | # @expected.mime_version = "1.0" 116 | # end 117 | 118 | # def charset 119 | # "UTF-8" 120 | # end 121 | # end 122 | 123 | # include Behavior 124 | # end 125 | # end 126 | -------------------------------------------------------------------------------- /lib/active_agent/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveAgent 2 | VERSION = "0.3.3" 3 | end 4 | -------------------------------------------------------------------------------- /lib/activeagent.rb: -------------------------------------------------------------------------------- 1 | require "active_agent" 2 | -------------------------------------------------------------------------------- /lib/generators/active_agent/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Generates a new agent and its views. Passes the agent name, either 3 | CamelCased or under_scored, and an optional list of prompts as arguments. 4 | 5 | This generates a agent class in app/agents and invokes your template 6 | engine and test framework generators. 7 | 8 | Examples: 9 | `bin/rails generate agent inventory search` 10 | 11 | creates a sign up mailer class, views, and test: 12 | Agent: app/agents/inventory_agent.rb 13 | Views: app/views/inventory_agent/search.text.erb [...] 14 | Test: test/agents/inventory_agent_test.rb 15 | 16 | `bin/rails generate agent inventory search update report` 17 | 18 | creates an inventory agent with search, update, and report actions. 19 | -------------------------------------------------------------------------------- /lib/generators/active_agent/agent_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent 4 | module Generators 5 | class AgentGenerator < ::Rails::Generators::NamedBase 6 | source_root File.expand_path("templates", __dir__) 7 | 8 | argument :actions, type: :array, default: [], banner: "method method" 9 | 10 | check_class_collision 11 | 12 | def create_agent_file 13 | template "agent.rb", File.join("app/agents", class_path, "#{file_name}.rb") 14 | 15 | in_root do 16 | if behavior == :invoke && !File.exist?(application_agent_file_name) 17 | template "application_agent.rb", application_agent_file_name 18 | end 19 | end 20 | end 21 | 22 | private 23 | 24 | def file_name # :doc: 25 | @_file_name ||= super + "_agent" 26 | end 27 | 28 | def application_agent_file_name 29 | @_application_agent_file_name ||= if mountable_engine? 30 | "app/agents/#{namespaced_path}/application_agent.rb" 31 | else 32 | "app/agents/application_agent.rb" 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/generators/active_agent/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveAgent 4 | module Generators 5 | class InstallGenerator < ::Rails::Generators::Base 6 | source_root File.expand_path("templates", __dir__) 7 | 8 | def create_configuration 9 | template "active_agent.yml", "config/active_agent.yml" 10 | end 11 | 12 | def create_application_agent 13 | template "application_agent.rb", "app/agents/application_agent.rb" 14 | end 15 | 16 | def create_agent_layouts 17 | template "agent.html.erb", "app/views/layouts/agent.html.erb" 18 | template "agent.text.erb", "app/views/layouts/agent.text.erb" 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/generators/active_agent/templates/action.html.erb.tt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/lib/generators/active_agent/templates/action.html.erb.tt -------------------------------------------------------------------------------- /lib/generators/active_agent/templates/action.json.jbuilder.tt: -------------------------------------------------------------------------------- 1 | json.type :function 2 | json.function do 3 | json.name action_name 4 | json.description "TODO: Write a description for this action" 5 | json.parameters do 6 | json.type :object 7 | json.properties do 8 | json.param_name do 9 | json.type :string 10 | json.description "The param_description" 11 | end 12 | end 13 | end 14 | end -------------------------------------------------------------------------------- /lib/generators/active_agent/templates/active_agent.yml: -------------------------------------------------------------------------------- 1 | development: 2 | openai: 3 | service: "OpenAI" 4 | api_key: <%%= Rails.application.credentials.dig(:openai, :api_key) %> 5 | model: "gpt-4o-mini" 6 | temperature: 0.7 -------------------------------------------------------------------------------- /lib/generators/active_agent/templates/agent.html.erb: -------------------------------------------------------------------------------- 1 | <%= yield if block_given? %> -------------------------------------------------------------------------------- /lib/generators/active_agent/templates/agent.rb.tt: -------------------------------------------------------------------------------- 1 | <% module_namespacing do -%> 2 | class <%= class_name %> < ApplicationAgent 3 | <% actions.each_with_index do |action, index| -%> 4 | <% if index != 0 -%> 5 | 6 | <% end -%> 7 | def <%= action %> 8 | @message = "Cats go.." 9 | 10 | prompt message: @message 11 | end 12 | <% end -%> 13 | end 14 | <% end -%> 15 | -------------------------------------------------------------------------------- /lib/generators/active_agent/templates/agent.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield if block_given? %> -------------------------------------------------------------------------------- /lib/generators/active_agent/templates/agent_spec.rb.tt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/lib/generators/active_agent/templates/agent_spec.rb.tt -------------------------------------------------------------------------------- /lib/generators/active_agent/templates/agent_test.rb.tt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/lib/generators/active_agent/templates/agent_test.rb.tt -------------------------------------------------------------------------------- /lib/generators/active_agent/templates/application_agent.rb.tt: -------------------------------------------------------------------------------- 1 | <% module_namespacing do -%> 2 | class ApplicationAgent < ActiveAgent::Base 3 | layout 'agent' 4 | 5 | generate_with :openai, model: "gpt-4o-mini", instructions: "You are a helpful assistant." 6 | 7 | def text_prompt 8 | prompt { |format| format.text { render plain: params[:message] } } 9 | end 10 | end 11 | <% end %> -------------------------------------------------------------------------------- /lib/tasks/activeagent_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :activeagent do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /test/application_agent_test.rb: -------------------------------------------------------------------------------- 1 | # test/application_agent_test.rb - additional test for embed functionality 2 | 3 | require "test_helper" 4 | 5 | class ApplicationAgentTest < ActiveSupport::TestCase 6 | test "it renders a prompt with an empty message" do 7 | assert_equal "", ApplicationAgent.text_prompt.message.content 8 | end 9 | 10 | test "it renders a prompt with an plain text message" do 11 | assert_equal "Test Application Agent", ApplicationAgent.with(message: "Test Application Agent").text_prompt.message.content 12 | end 13 | 14 | test "embed generates vector for message content" do 15 | VCR.use_cassette("application_agent_message_embedding") do 16 | message = ActiveAgent::ActionPrompt::Message.new(content: "Test content for embedding") 17 | response = message.embed 18 | 19 | assert_not_nil response 20 | assert_equal message, response 21 | # Assuming your provider returns a vector when embed is called 22 | assert_not_nil response.content 23 | end 24 | end 25 | 26 | test "embed can be called directly on an agent instance" do 27 | VCR.use_cassette("application_agent_embeddings") do 28 | agent = ApplicationAgent.new 29 | agent.prompt_context = ActiveAgent::ActionPrompt::Prompt.new( 30 | message: ActiveAgent::ActionPrompt::Message.new(content: "Test direct embedding") 31 | ) 32 | response = agent.embed 33 | 34 | assert_not_nil response 35 | assert_instance_of ActiveAgent::GenerationProvider::Response, response 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/dummy/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" 4 | gem "rails", "~> 8.0.2" 5 | # Use sqlite3 as the database for Active Record 6 | gem "sqlite3", ">= 2.1" 7 | # Use the Puma web server [https://github.com/puma/puma] 8 | gem "puma", ">= 5.0" 9 | # Build JSON APIs with ease [https://github.com/rails/jbuilder] 10 | gem "jbuilder" 11 | # Use Redis adapter to run Action Cable in production 12 | # gem "redis", ">= 4.0.1" 13 | 14 | # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] 15 | # gem "bcrypt", "~> 3.1.7" 16 | 17 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 18 | gem "tzinfo-data", platforms: %i[windows jruby] 19 | 20 | # Reduces boot times through caching; required in config/boot.rb 21 | gem "bootsnap", require: false 22 | 23 | # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] 24 | # gem "image_processing", "~> 1.2" 25 | 26 | group :development, :test do 27 | # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem 28 | gem "debug", platforms: %i[mri windows], require: "debug/prelude" 29 | end 30 | 31 | group :development do 32 | # Use console on exceptions pages [https://github.com/rails/web-console] 33 | gem "web-console" 34 | end 35 | 36 | group :test do 37 | # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] 38 | gem "capybara" 39 | gem "selenium-webdriver" 40 | end 41 | # Use Active Agent gem 42 | gem "activeagent", path: "../../.." 43 | -------------------------------------------------------------------------------- /test/dummy/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../../.. 3 | specs: 4 | activeagent (0.3.3) 5 | actionpack (>= 7.2, < 9.0) 6 | actionview (>= 7.2, < 9.0) 7 | activejob (>= 7.2, < 9.0) 8 | activemodel (>= 7.2, < 9.0) 9 | activesupport (>= 7.2, < 9.0) 10 | rails (>= 7.2, < 9.0) 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | actioncable (8.0.2) 16 | actionpack (= 8.0.2) 17 | activesupport (= 8.0.2) 18 | nio4r (~> 2.0) 19 | websocket-driver (>= 0.6.1) 20 | zeitwerk (~> 2.6) 21 | actionmailbox (8.0.2) 22 | actionpack (= 8.0.2) 23 | activejob (= 8.0.2) 24 | activerecord (= 8.0.2) 25 | activestorage (= 8.0.2) 26 | activesupport (= 8.0.2) 27 | mail (>= 2.8.0) 28 | actionmailer (8.0.2) 29 | actionpack (= 8.0.2) 30 | actionview (= 8.0.2) 31 | activejob (= 8.0.2) 32 | activesupport (= 8.0.2) 33 | mail (>= 2.8.0) 34 | rails-dom-testing (~> 2.2) 35 | actionpack (8.0.2) 36 | actionview (= 8.0.2) 37 | activesupport (= 8.0.2) 38 | nokogiri (>= 1.8.5) 39 | rack (>= 2.2.4) 40 | rack-session (>= 1.0.1) 41 | rack-test (>= 0.6.3) 42 | rails-dom-testing (~> 2.2) 43 | rails-html-sanitizer (~> 1.6) 44 | useragent (~> 0.16) 45 | actiontext (8.0.2) 46 | actionpack (= 8.0.2) 47 | activerecord (= 8.0.2) 48 | activestorage (= 8.0.2) 49 | activesupport (= 8.0.2) 50 | globalid (>= 0.6.0) 51 | nokogiri (>= 1.8.5) 52 | actionview (8.0.2) 53 | activesupport (= 8.0.2) 54 | builder (~> 3.1) 55 | erubi (~> 1.11) 56 | rails-dom-testing (~> 2.2) 57 | rails-html-sanitizer (~> 1.6) 58 | activejob (8.0.2) 59 | activesupport (= 8.0.2) 60 | globalid (>= 0.3.6) 61 | activemodel (8.0.2) 62 | activesupport (= 8.0.2) 63 | activerecord (8.0.2) 64 | activemodel (= 8.0.2) 65 | activesupport (= 8.0.2) 66 | timeout (>= 0.4.0) 67 | activestorage (8.0.2) 68 | actionpack (= 8.0.2) 69 | activejob (= 8.0.2) 70 | activerecord (= 8.0.2) 71 | activesupport (= 8.0.2) 72 | marcel (~> 1.0) 73 | activesupport (8.0.2) 74 | base64 75 | benchmark (>= 0.3) 76 | bigdecimal 77 | concurrent-ruby (~> 1.0, >= 1.3.1) 78 | connection_pool (>= 2.2.5) 79 | drb 80 | i18n (>= 1.6, < 2) 81 | logger (>= 1.4.2) 82 | minitest (>= 5.1) 83 | securerandom (>= 0.3) 84 | tzinfo (~> 2.0, >= 2.0.5) 85 | uri (>= 0.13.1) 86 | addressable (2.8.7) 87 | public_suffix (>= 2.0.2, < 7.0) 88 | base64 (0.2.0) 89 | benchmark (0.4.0) 90 | bigdecimal (3.1.9) 91 | bindex (0.8.1) 92 | bootsnap (1.18.4) 93 | msgpack (~> 1.2) 94 | builder (3.3.0) 95 | capybara (3.40.0) 96 | addressable 97 | matrix 98 | mini_mime (>= 0.1.3) 99 | nokogiri (~> 1.11) 100 | rack (>= 1.6.0) 101 | rack-test (>= 0.6.3) 102 | regexp_parser (>= 1.5, < 3.0) 103 | xpath (~> 3.2) 104 | concurrent-ruby (1.3.5) 105 | connection_pool (2.5.3) 106 | crass (1.0.6) 107 | date (3.4.1) 108 | debug (1.10.0) 109 | irb (~> 1.10) 110 | reline (>= 0.3.8) 111 | drb (2.2.1) 112 | erubi (1.13.1) 113 | globalid (1.2.1) 114 | activesupport (>= 6.1) 115 | i18n (1.14.7) 116 | concurrent-ruby (~> 1.0) 117 | io-console (0.8.0) 118 | irb (1.15.2) 119 | pp (>= 0.6.0) 120 | rdoc (>= 4.0.0) 121 | reline (>= 0.4.2) 122 | jbuilder (2.13.0) 123 | actionview (>= 5.0.0) 124 | activesupport (>= 5.0.0) 125 | logger (1.7.0) 126 | loofah (2.24.0) 127 | crass (~> 1.0.2) 128 | nokogiri (>= 1.12.0) 129 | mail (2.8.1) 130 | mini_mime (>= 0.1.1) 131 | net-imap 132 | net-pop 133 | net-smtp 134 | marcel (1.0.4) 135 | matrix (0.4.2) 136 | mini_mime (1.1.5) 137 | minitest (5.25.5) 138 | msgpack (1.8.0) 139 | net-imap (0.5.8) 140 | date 141 | net-protocol 142 | net-pop (0.1.2) 143 | net-protocol 144 | net-protocol (0.2.2) 145 | timeout 146 | net-smtp (0.5.1) 147 | net-protocol 148 | nio4r (2.7.4) 149 | nokogiri (1.18.8-aarch64-linux-gnu) 150 | racc (~> 1.4) 151 | nokogiri (1.18.8-aarch64-linux-musl) 152 | racc (~> 1.4) 153 | nokogiri (1.18.8-arm-linux-gnu) 154 | racc (~> 1.4) 155 | nokogiri (1.18.8-arm-linux-musl) 156 | racc (~> 1.4) 157 | nokogiri (1.18.8-arm64-darwin) 158 | racc (~> 1.4) 159 | nokogiri (1.18.8-x86_64-darwin) 160 | racc (~> 1.4) 161 | nokogiri (1.18.8-x86_64-linux-gnu) 162 | racc (~> 1.4) 163 | nokogiri (1.18.8-x86_64-linux-musl) 164 | racc (~> 1.4) 165 | pp (0.6.2) 166 | prettyprint 167 | prettyprint (0.2.0) 168 | psych (5.2.4) 169 | date 170 | stringio 171 | public_suffix (6.0.2) 172 | puma (6.6.0) 173 | nio4r (~> 2.0) 174 | racc (1.8.1) 175 | rack (3.1.13) 176 | rack-session (2.1.0) 177 | base64 (>= 0.1.0) 178 | rack (>= 3.0.0) 179 | rack-test (2.2.0) 180 | rack (>= 1.3) 181 | rackup (2.2.1) 182 | rack (>= 3) 183 | rails (8.0.2) 184 | actioncable (= 8.0.2) 185 | actionmailbox (= 8.0.2) 186 | actionmailer (= 8.0.2) 187 | actionpack (= 8.0.2) 188 | actiontext (= 8.0.2) 189 | actionview (= 8.0.2) 190 | activejob (= 8.0.2) 191 | activemodel (= 8.0.2) 192 | activerecord (= 8.0.2) 193 | activestorage (= 8.0.2) 194 | activesupport (= 8.0.2) 195 | bundler (>= 1.15.0) 196 | railties (= 8.0.2) 197 | rails-dom-testing (2.2.0) 198 | activesupport (>= 5.0.0) 199 | minitest 200 | nokogiri (>= 1.6) 201 | rails-html-sanitizer (1.6.2) 202 | loofah (~> 2.21) 203 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 204 | railties (8.0.2) 205 | actionpack (= 8.0.2) 206 | activesupport (= 8.0.2) 207 | irb (~> 1.13) 208 | rackup (>= 1.0.0) 209 | rake (>= 12.2) 210 | thor (~> 1.0, >= 1.2.2) 211 | zeitwerk (~> 2.6) 212 | rake (13.2.1) 213 | rdoc (6.13.1) 214 | psych (>= 4.0.0) 215 | regexp_parser (2.10.0) 216 | reline (0.6.1) 217 | io-console (~> 0.5) 218 | rexml (3.4.1) 219 | rubyzip (2.4.1) 220 | securerandom (0.4.1) 221 | selenium-webdriver (4.32.0) 222 | base64 (~> 0.2) 223 | logger (~> 1.4) 224 | rexml (~> 3.2, >= 3.2.5) 225 | rubyzip (>= 1.2.2, < 3.0) 226 | websocket (~> 1.0) 227 | sqlite3 (2.6.0-aarch64-linux-gnu) 228 | sqlite3 (2.6.0-aarch64-linux-musl) 229 | sqlite3 (2.6.0-arm-linux-gnu) 230 | sqlite3 (2.6.0-arm-linux-musl) 231 | sqlite3 (2.6.0-arm64-darwin) 232 | sqlite3 (2.6.0-x86_64-darwin) 233 | sqlite3 (2.6.0-x86_64-linux-gnu) 234 | sqlite3 (2.6.0-x86_64-linux-musl) 235 | stringio (3.1.7) 236 | thor (1.3.2) 237 | timeout (0.4.3) 238 | tzinfo (2.0.6) 239 | concurrent-ruby (~> 1.0) 240 | uri (1.0.3) 241 | useragent (0.16.11) 242 | web-console (4.2.1) 243 | actionview (>= 6.0.0) 244 | activemodel (>= 6.0.0) 245 | bindex (>= 0.4.0) 246 | railties (>= 6.0.0) 247 | websocket (1.2.11) 248 | websocket-driver (0.7.7) 249 | base64 250 | websocket-extensions (>= 0.1.0) 251 | websocket-extensions (0.1.5) 252 | xpath (3.2.0) 253 | nokogiri (~> 1.8) 254 | zeitwerk (2.7.2) 255 | 256 | PLATFORMS 257 | aarch64-linux 258 | aarch64-linux-gnu 259 | aarch64-linux-musl 260 | arm-linux 261 | arm-linux-gnu 262 | arm-linux-musl 263 | arm64-darwin 264 | x86_64-darwin 265 | x86_64-linux 266 | x86_64-linux-gnu 267 | x86_64-linux-musl 268 | 269 | DEPENDENCIES 270 | activeagent! 271 | bootsnap 272 | capybara 273 | debug 274 | jbuilder 275 | puma (>= 5.0) 276 | rails (~> 8.0.2) 277 | selenium-webdriver 278 | sqlite3 (>= 2.1) 279 | tzinfo-data 280 | web-console 281 | 282 | BUNDLED WITH 283 | 2.6.5 284 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/app/agents/application_agent.rb: -------------------------------------------------------------------------------- 1 | class ApplicationAgent < ActiveAgent::Base 2 | layout "agent" 3 | 4 | generate_with :openai, 5 | model: "gpt-4o-mini", 6 | instructions: "You're just a basic agent", 7 | stream: true 8 | 9 | def text_prompt 10 | prompt(stream: params[:stream], message: params[:message], context_id: params[:context_id]) { |format| format.text { render plain: params[:message] } } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/dummy/app/agents/ollama_agent.rb: -------------------------------------------------------------------------------- 1 | class OllamaAgent < ApplicationAgent 2 | layout "agent" 3 | generate_with :ollama, model: "gemma3:latest", instructions: "You're a basic Ollama agent." 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/agents/open_ai_agent.rb: -------------------------------------------------------------------------------- 1 | class OpenAIAgent < ApplicationAgent 2 | layout "agent" 3 | generate_with :openai, model: "gpt-4o-mini", instructions: "You're a basic OpenAI agent." 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/agents/open_router_agent.rb: -------------------------------------------------------------------------------- 1 | class OpenRouterAgent < ApplicationAgent 2 | layout "agent" 3 | generate_with :open_router, model: "qwen/qwen3-30b-a3b:free", instructions: "You're a basic Open Router agent." 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/agents/support_agent.rb: -------------------------------------------------------------------------------- 1 | class SupportAgent < ApplicationAgent 2 | layout "agent" 3 | generate_with :openai, model: "gpt-4o-mini", instructions: "You're a support agent. Your job is to help users with their questions." 4 | 5 | def get_cat_image 6 | prompt(content_type: "image_url", context_id: params[:context_id]) do |format| 7 | format.text { render plain: CatImageService.fetch_base64_image } 8 | format.json 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/test/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* Application styles */ 2 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. 3 | allow_browser versions: :modern 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/test/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/test/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/services/cat_image_service.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | require "base64" 3 | 4 | class CatImageService 5 | def self.fetch_base64_image 6 | uri = URI("https://cataas.com/cat") 7 | response = Net::HTTP.get_response(uri) 8 | 9 | if response.is_a?(Net::HTTPSuccess) 10 | image_data = response.body 11 | "data:image/jpeg;base64,#{Base64.strict_encode64(image_data)}" 12 | else 13 | raise "Failed to fetch cat image. Status code: #{response.code}" 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/agent.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for(:title) || "Dummy" %> 5 | 6 | 7 | 8 | <%= csrf_meta_tags %> 9 | <%= csp_meta_tag %> 10 | 11 | <%= yield :head %> 12 | 13 | <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> 14 | <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> 15 | 16 | 17 | 18 | 19 | 20 | <%# Includes all stylesheet files in app/assets/stylesheets %> 21 | <%= stylesheet_link_tag "application" %> 22 | 23 | 24 | 25 | <%= yield %> 26 | 27 | 28 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /test/dummy/app/views/pwa/manifest.json.erb: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dummy", 3 | "icons": [ 4 | { 5 | "src": "/icon.png", 6 | "type": "image/png", 7 | "sizes": "512x512" 8 | }, 9 | { 10 | "src": "/icon.png", 11 | "type": "image/png", 12 | "sizes": "512x512", 13 | "purpose": "maskable" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "scope": "/", 19 | "description": "Dummy.", 20 | "theme_color": "red", 21 | "background_color": "red" 22 | } 23 | -------------------------------------------------------------------------------- /test/dummy/app/views/pwa/service-worker.js: -------------------------------------------------------------------------------- 1 | // Add a service worker for processing Web Push notifications: 2 | // 3 | // self.addEventListener("push", async (event) => { 4 | // const { title, options } = await event.data.json() 5 | // event.waitUntil(self.registration.showNotification(title, options)) 6 | // }) 7 | // 8 | // self.addEventListener("notificationclick", function(event) { 9 | // event.notification.close() 10 | // event.waitUntil( 11 | // clients.matchAll({ type: "window" }).then((clientList) => { 12 | // for (let i = 0; i < clientList.length; i++) { 13 | // let client = clientList[i] 14 | // let clientPath = (new URL(client.url)).pathname 15 | // 16 | // if (clientPath == event.notification.data.path && "focus" in client) { 17 | // return client.focus() 18 | // } 19 | // } 20 | // 21 | // if (clients.openWindow) { 22 | // return clients.openWindow(event.notification.data.path) 23 | // } 24 | // }) 25 | // ) 26 | // }) 27 | -------------------------------------------------------------------------------- /test/dummy/app/views/support_agent/get_cat_image.json.erb: -------------------------------------------------------------------------------- 1 | <%= { 2 | type: :function, 3 | function: { 4 | name: action_name, 5 | description: "This action takes no params and gets a random cat image and returns it as a base64 string.", 6 | parameters: { 7 | type: :object, 8 | properties: { 9 | param_name: { 10 | type: :string, 11 | description: "The param_description" 12 | } 13 | } 14 | } 15 | } 16 | }.to_json.html_safe %> -------------------------------------------------------------------------------- /test/dummy/app/views/support_agent/get_cat_image.text.erb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/test/dummy/app/views/support_agent/get_cat_image.text.erb -------------------------------------------------------------------------------- /test/dummy/bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | exec "./bin/rails", "server", *ARGV 3 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | 6 | def system!(*args) 7 | system(*args, exception: true) 8 | end 9 | 10 | FileUtils.chdir APP_ROOT do 11 | # This script is a way to set up or update your development environment automatically. 12 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 13 | # Add necessary setup steps to this file. 14 | 15 | puts "== Installing dependencies ==" 16 | system("bundle check") || system!("bundle install") 17 | 18 | # puts "\n== Copying sample files ==" 19 | # unless File.exist?("config/database.yml") 20 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 21 | # end 22 | 23 | puts "\n== Preparing database ==" 24 | system! "bin/rails db:prepare" 25 | 26 | puts "\n== Removing old logs and tempfiles ==" 27 | system! "bin/rails log:clear tmp:clear" 28 | 29 | unless ARGV.include?("--skip-server") 30 | puts "\n== Starting development server ==" 31 | STDOUT.flush # flush the output before exec(2) so that it displays 32 | exec "bin/dev" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/dummy/config/active_agent.yml: -------------------------------------------------------------------------------- 1 | development: 2 | openai: 3 | service: "OpenAI" 4 | api_key: <%= Rails.application.credentials.dig(:openai, :api_key) %> 5 | model: "gpt-4o-mini" 6 | temperature: 0.7 7 | open_router: 8 | service: "OpenRouter" 9 | api_key: <%= Rails.application.credentials.dig(:open_router, :api_key) %> 10 | model: "qwen/qwen3-30b-a3b:free" 11 | temperature: 0.7 12 | ollama: 13 | service: "Ollama" 14 | api_key: "" 15 | model: "gemma3:latest" 16 | temperature: 0.7 17 | test: 18 | openai: 19 | service: "OpenAI" 20 | api_key: <%= Rails.application.credentials.dig(:openai, :api_key) %> 21 | model: "gpt-o3-mini" 22 | temperature: 0.7 23 | open_router: 24 | service: "OpenRouter" 25 | api_key: <%= Rails.application.credentials.dig(:open_router, :api_key) %> 26 | model: "qwen/qwen3-30b-a3b:free" 27 | temperature: 0.7 28 | ollama: 29 | service: "Ollama" 30 | api_key: "" 31 | model: "gemma3:latest" 32 | temperature: 0.7 -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | config.load_defaults Rails::VERSION::STRING.to_f 12 | 13 | # Please, add to the `ignore` list any other `lib` subdirectories that do 14 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 15 | # Common ones are `templates`, `generators`, or `middleware`, for example. 16 | config.autoload_lib(ignore: %w[assets tasks]) 17 | 18 | # Configuration for the application, engines, and railties goes here. 19 | # 20 | # These settings can be overridden in specific environments using the files 21 | # in config/environments, which are processed later. 22 | # 23 | # config.time_zone = "Central Time (US & Canada)" 24 | # config.eager_load_paths << Rails.root.join("extras") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /test/dummy/config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | CDFxcODwfko0IbsvNh02j4PPl0hBmn717+mscjO7A0WtOyNA+YP8n01PAzuPIWaJCOVhJdXAEkNc2HmHR1WEHL2TM16G3Qd5YFceQuHzZN2NB9Tl3Ryx5ccK5jOOH485CIsf55EJfTFps+zUD6b07lIGyV3TpJkEolvuiHd4zLoKWucclmDe69g21Jxp8bpZJm8KubrgHDMivlXMp4yGym81i0gwznQg4meGxsyRqeBgAkLs2lCv0ZDeNxuvN6JnQcyOiGBAfyF7eVHg6rja6IXno44oB9kRtpnVqIVXxKACHhjJxCgv7AKv+f3BGO0YftxZY/nz7o4UFm/89+u9GnhKszDUZoRpvYoKnebn4q7p11gEgx2a6Mb9Gh+LamfkBeKTfEDfmwgspxel//BDIeT8mtQeX1Es9T9WIijrsPErV+UqHmO8bXdanuVhnmudZiJFbVlwXpm69V6JAqAJ96tCkUxfx5lN5GIroVBParVlmARV7dnlZjuKtpsL60gafTc+F/6NVt3eKvsTysnpG4crQvh9fuh5V4vujP8zZ7lWNpKAuWAeI6AorSD7R6IouvKqLjNqd6ljhfGnzVJmJlVxpzSKQ0bWSVnsoGZplfm9xWPnZB74serdwfFlnD6kUGGsmRZ4ZC8A7LH3TOAhz2iaVxDvN5im3K0CNr2x3+3GGarjhLg7QqaPwXVPULJatEoTk+oqyluDdp6+y43BEmwA2mMuZCggX8I=--5TN8EC6i07FodlKZ--79Eh4p6gDOCANQRrKF96FQ== -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: storage/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: storage/test.sqlite3 22 | 23 | 24 | # SQLite3 write its data on the local filesystem, as such it requires 25 | # persistent disks. If you are deploying to a managed service, you should 26 | # make sure it provides disk persistence, as many don't. 27 | # 28 | # Similarly, if you deploy your application as a Docker container, you must 29 | # ensure the database is located in a persisted volume. 30 | production: 31 | <<: *default 32 | # database: path/to/persistent/storage/production.sqlite3 33 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Make code changes take effect immediately without server restart. 7 | config.enable_reloading = true 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable server timing. 16 | config.server_timing = true 17 | 18 | # Enable/disable Action Controller caching. By default Action Controller caching is disabled. 19 | # Run rails dev:cache to toggle Action Controller caching. 20 | if Rails.root.join("tmp/caching-dev.txt").exist? 21 | config.action_controller.perform_caching = true 22 | config.action_controller.enable_fragment_cache_logging = true 23 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } 24 | else 25 | config.action_controller.perform_caching = false 26 | end 27 | 28 | # Change to :null_store to avoid any caching. 29 | config.cache_store = :memory_store 30 | 31 | # Store uploaded files on the local file system (see config/storage.yml for options). 32 | config.active_storage.service = :local 33 | 34 | # Don't care if the mailer can't send. 35 | config.action_mailer.raise_delivery_errors = false 36 | 37 | # Make template changes take effect immediately. 38 | config.action_mailer.perform_caching = false 39 | 40 | # Set localhost to be used by links generated in mailer templates. 41 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 } 42 | 43 | # Print deprecation notices to the Rails logger. 44 | config.active_support.deprecation = :log 45 | 46 | # Raise an error on page load if there are pending migrations. 47 | config.active_record.migration_error = :page_load 48 | 49 | # Highlight code that triggered database queries in logs. 50 | config.active_record.verbose_query_logs = true 51 | 52 | # Append comments with runtime information tags to SQL queries in logs. 53 | config.active_record.query_log_tags_enabled = true 54 | 55 | # Highlight code that enqueued background job in logs. 56 | config.active_job.verbose_enqueue_logs = true 57 | 58 | # Raises error for missing translations. 59 | # config.i18n.raise_on_missing_translations = true 60 | 61 | # Annotate rendered view with file names. 62 | config.action_view.annotate_rendered_view_with_filenames = true 63 | 64 | # Uncomment if you wish to allow Action Cable access from any origin. 65 | # config.action_cable.disable_request_forgery_protection = true 66 | 67 | # Raise error when a before_action's only/except options reference missing actions. 68 | config.action_controller.raise_on_missing_callback_actions = true 69 | end 70 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.enable_reloading = false 8 | 9 | # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). 10 | config.eager_load = true 11 | 12 | # Full error reports are disabled. 13 | config.consider_all_requests_local = false 14 | 15 | # Turn on fragment caching in view templates. 16 | config.action_controller.perform_caching = true 17 | 18 | # Cache assets for far-future expiry since they are all digest stamped. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } 20 | 21 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 22 | # config.asset_host = "http://assets.example.com" 23 | 24 | # Store uploaded files on the local file system (see config/storage.yml for options). 25 | config.active_storage.service = :local 26 | 27 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 28 | config.assume_ssl = true 29 | 30 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 31 | config.force_ssl = true 32 | 33 | # Skip http-to-https redirect for the default health check endpoint. 34 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 35 | 36 | # Log to STDOUT with the current request id as a default log tag. 37 | config.log_tags = [ :request_id ] 38 | config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) 39 | 40 | # Change to "debug" to log everything (including potentially personally-identifiable information!) 41 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 42 | 43 | # Prevent health checks from clogging up the logs. 44 | config.silence_healthcheck_path = "/up" 45 | 46 | # Don't log any deprecations. 47 | config.active_support.report_deprecations = false 48 | 49 | # Replace the default in-process memory cache store with a durable alternative. 50 | # config.cache_store = :mem_cache_store 51 | 52 | # Replace the default in-process and non-durable queuing backend for Active Job. 53 | # config.active_job.queue_adapter = :resque 54 | 55 | # Ignore bad email addresses and do not raise email delivery errors. 56 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 57 | # config.action_mailer.raise_delivery_errors = false 58 | 59 | # Set host to be used by links generated in mailer templates. 60 | config.action_mailer.default_url_options = { host: "example.com" } 61 | 62 | # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. 63 | # config.action_mailer.smtp_settings = { 64 | # user_name: Rails.application.credentials.dig(:smtp, :user_name), 65 | # password: Rails.application.credentials.dig(:smtp, :password), 66 | # address: "smtp.example.com", 67 | # port: 587, 68 | # authentication: :plain 69 | # } 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Do not dump schema after migrations. 76 | config.active_record.dump_schema_after_migration = false 77 | 78 | # Only use :id for inspections in production. 79 | config.active_record.attributes_for_inspect = [ :id ] 80 | 81 | # Enable DNS rebinding protection and other `Host` header attacks. 82 | # config.hosts = [ 83 | # "example.com", # Allow requests from example.com 84 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 85 | # ] 86 | # 87 | # Skip DNS rebinding protection for the default health check endpoint. 88 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 89 | end 90 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | 6 | Rails.application.configure do 7 | # Settings specified here will take precedence over those in config/application.rb. 8 | 9 | # While tests run files are not watched, reloading is not necessary. 10 | config.enable_reloading = false 11 | 12 | # Eager loading loads your entire application. When running a single test locally, 13 | # this is usually not necessary, and can slow down your test suite. However, it's 14 | # recommended that you enable it in continuous integration systems to ensure eager 15 | # loading is working properly before deploying your code. 16 | config.eager_load = ENV["CI"].present? 17 | 18 | # Configure public file server for tests with cache-control for performance. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } 20 | 21 | # Show full error reports. 22 | config.consider_all_requests_local = true 23 | config.cache_store = :null_store 24 | 25 | # Render exception templates for rescuable exceptions and raise for other exceptions. 26 | config.action_dispatch.show_exceptions = :rescuable 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Store uploaded files on the local file system in a temporary directory. 32 | config.active_storage.service = :test 33 | 34 | # Tell Action Mailer not to deliver emails to the real world. 35 | # The :test delivery method accumulates sent emails in the 36 | # ActionMailer::Base.deliveries array. 37 | config.action_mailer.delivery_method = :test 38 | 39 | # Set host to be used by links generated in mailer templates. 40 | config.action_mailer.default_url_options = { host: "example.com" } 41 | 42 | # Print deprecation notices to the stderr. 43 | config.active_support.deprecation = :stderr 44 | 45 | # Raises error for missing translations. 46 | # config.i18n.raise_on_missing_translations = true 47 | 48 | # Annotate rendered view with file names. 49 | # config.action_view.annotate_rendered_view_with_filenames = true 50 | 51 | # Raise error when a before_action's only/except options reference missing actions. 52 | config.action_controller.raise_on_missing_callback_actions = true 53 | end 54 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc 8 | ] 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | # Ensure OpenAI acronym is preserved correctly 13 | ActiveSupport::Inflector.inflections(:en) do |inflect| 14 | inflect.acronym "AI" 15 | end 16 | # These inflection rules are supported but not enabled by default: 17 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 18 | # inflect.acronym "RESTful" 19 | # end 20 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | hello: "Hello world" 32 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | # 5 | # Puma starts a configurable number of processes (workers) and each process 6 | # serves each request in a thread from an internal thread pool. 7 | # 8 | # You can control the number of workers using ENV["WEB_CONCURRENCY"]. You 9 | # should only set this value when you want to run 2 or more workers. The 10 | # default is already 1. 11 | # 12 | # The ideal number of threads per worker depends both on how much time the 13 | # application spends waiting for IO operations and on how much you wish to 14 | # prioritize throughput over latency. 15 | # 16 | # As a rule of thumb, increasing the number of threads will increase how much 17 | # traffic a given process can handle (throughput), but due to CRuby's 18 | # Global VM Lock (GVL) it has diminishing returns and will degrade the 19 | # response time (latency) of the application. 20 | # 21 | # The default is set to 3 threads as it's deemed a decent compromise between 22 | # throughput and latency for the average Rails application. 23 | # 24 | # Any libraries that use a connection pool or another resource pool should 25 | # be configured to provide at least as many connections as the number of 26 | # threads. This includes Active Record's `pool` parameter in `database.yml`. 27 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) 28 | threads threads_count, threads_count 29 | 30 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 31 | port ENV.fetch("PORT", 3000) 32 | 33 | # Allow puma to be restarted by `bin/rails restart` command. 34 | plugin :tmp_restart 35 | 36 | # Specify the PID file. Defaults to tmp/pids/server.pid in development. 37 | # In other environments, only set the PID file if requested. 38 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"] 39 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html 3 | 4 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. 5 | # Can be used by load balancers and uptime monitors to verify that the app is live. 6 | get "up" => "rails/health#show", :as => :rails_health_check 7 | 8 | # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) 9 | # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest 10 | # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker 11 | 12 | # Defines the root path route ("/") 13 | # root "posts#index" 14 | end 15 | -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/test/dummy/log/.keep -------------------------------------------------------------------------------- /test/dummy/public/400.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The server cannot process the request due to a client error (400 Bad Request) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The page you were looking for doesn’t exist (404 Not found) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /test/dummy/public/406-unsupported-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Your browser is not supported (406 Not Acceptable) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

Your browser is not supported.
Please upgrade your browser to continue.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The change you wanted was rejected (422 Unprocessable Entity) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | We’re sorry, but something went wrong (500 Internal Server Error) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

We’re sorry, but something went wrong.
If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /test/dummy/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/activeagents/activeagent/2eaa74ba8d05e8d7adbcb3b5747fa9ac66d80522/test/dummy/public/icon.png -------------------------------------------------------------------------------- /test/dummy/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/ollama_agent_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class OllamaAgentTest < ActiveSupport::TestCase 4 | test "it renders a text_prompt and generates a response" do 5 | VCR.use_cassette("ollama_text_prompt_response") do 6 | message = "Show me a cat" 7 | prompt = OllamaAgent.with(message: message).text_prompt 8 | response = prompt.generate_now 9 | 10 | assert_equal message, OllamaAgent.with(message: message).text_prompt.message.content 11 | assert_equal 3, response.prompt.messages.size 12 | assert_equal :system, response.prompt.messages[0].role 13 | assert_equal :user, response.prompt.messages[1].role 14 | assert_equal message, response.prompt.messages[1].content 15 | assert_equal :assistant, response.prompt.messages[2].role 16 | end 17 | end 18 | 19 | test "it uses the correct model" do 20 | prompt = OllamaAgent.new.text_prompt 21 | assert_equal "gemma3:latest", prompt.options[:model] 22 | end 23 | 24 | test "it sets the correct system instructions" do 25 | prompt = OllamaAgent.new.text_prompt 26 | system_message = prompt.messages.find { |m| m.role == :system } 27 | assert_equal "You're a basic Ollama agent.", system_message.content 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/open_ai_agent_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class OpenAIAgentTest < ActiveSupport::TestCase 4 | test "it renders a text_prompt generates a response" do 5 | VCR.use_cassette("openai_text_prompt_response") do 6 | message = "Show me a cat" 7 | prompt = OpenAIAgent.with(message: message).text_prompt 8 | response = prompt.generate_now 9 | assert_equal message, OpenAIAgent.with(message: message).text_prompt.message.content 10 | assert_equal 3, response.prompt.messages.size 11 | assert_equal :system, response.prompt.messages[0].role 12 | assert_equal :user, response.prompt.messages[1].role 13 | assert_equal :assistant, response.prompt.messages[2].role 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/open_router_agent_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class OpenRouterAgentTest < ActiveSupport::TestCase 4 | test "it renders a text_prompt and generates a response" do 5 | VCR.use_cassette("open_router_text_prompt_response") do 6 | message = "Show me a cat" 7 | prompt = OpenRouterAgent.with(message: message).text_prompt 8 | response = prompt.generate_now 9 | 10 | assert_equal message, OpenRouterAgent.with(message: message).text_prompt.message.content 11 | assert_equal 3, response.prompt.messages.size 12 | assert_equal :system, response.prompt.messages[0].role 13 | assert_equal :user, response.prompt.messages[1].role 14 | assert_equal message, response.prompt.messages[1].content 15 | assert_equal :assistant, response.prompt.messages[2].role 16 | end 17 | end 18 | 19 | test "it uses the correct model" do 20 | prompt = OpenRouterAgent.new.text_prompt 21 | assert_equal "qwen/qwen3-30b-a3b:free", prompt.options[:model] 22 | end 23 | 24 | test "it sets the correct system instructions" do 25 | prompt = OpenRouterAgent.new.text_prompt 26 | system_message = prompt.messages.find { |m| m.role == :system } 27 | assert_equal "You're a basic Open Router agent.", system_message.content 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/prompt_test.rb: -------------------------------------------------------------------------------- 1 | # filepath: lib/active_agent/action_prompt/prompt_test.rb 2 | 3 | require "test_helper" 4 | 5 | module ActiveAgent 6 | module ActionPrompt 7 | class PromptTest < ActiveSupport::TestCase 8 | test "initializes with default attributes" do 9 | prompt = Prompt.new 10 | 11 | assert_equal({}, prompt.options) 12 | assert_equal ApplicationAgent, prompt.agent_class 13 | assert_equal [], prompt.actions 14 | assert_equal "", prompt.action_choice 15 | assert_equal "", prompt.instructions 16 | assert_equal "", prompt.body 17 | assert_equal "text/plain", prompt.content_type 18 | assert_nil prompt.message 19 | assert_equal [], prompt.messages 20 | assert_equal({}, prompt.params) 21 | assert_equal "1.0", prompt.mime_version 22 | assert_equal "UTF-8", prompt.charset 23 | assert_equal [], prompt.context 24 | assert_nil prompt.context_id 25 | assert_equal({}, prompt.instance_variable_get(:@headers)) 26 | assert_equal [], prompt.parts 27 | end 28 | 29 | test "initializes with custom attributes" do 30 | attributes = { 31 | options: { key: "value" }, 32 | agent_class: ApplicationAgent, 33 | actions: [ "action1" ], 34 | action_choice: "action1", 35 | instructions: "Test instructions", 36 | body: "Test body", 37 | content_type: "application/json", 38 | message: "Test message", 39 | messages: [ Message.new(content: "Existing message") ], 40 | params: { param1: "value1" }, 41 | mime_version: "2.0", 42 | charset: "ISO-8859-1", 43 | context: [ "context1" ], 44 | context_id: "123", 45 | headers: { "Header-Key" => "Header-Value" }, 46 | parts: [ "part1" ] 47 | } 48 | 49 | prompt = Prompt.new(attributes) 50 | 51 | assert_equal attributes[:options], prompt.options 52 | assert_equal attributes[:agent_class], prompt.agent_class 53 | assert_equal attributes[:actions], prompt.actions 54 | assert_equal attributes[:action_choice], prompt.action_choice 55 | assert_equal attributes[:instructions], prompt.instructions 56 | assert_equal attributes[:body], prompt.body 57 | assert_equal attributes[:content_type], prompt.content_type 58 | assert_equal attributes[:message], prompt.message.content 59 | assert_equal ([ Message.new(content: "Test instructions", role: :system) ] + attributes[:messages]).map(&:to_h), prompt.messages.map(&:to_h) 60 | assert_equal attributes[:params], prompt.params 61 | assert_equal attributes[:mime_version], prompt.mime_version 62 | assert_equal attributes[:charset], prompt.charset 63 | assert_equal attributes[:context], prompt.context 64 | assert_equal attributes[:context_id], prompt.context_id 65 | assert_equal attributes[:headers], prompt.instance_variable_get(:@headers) 66 | assert_equal attributes[:parts], prompt.parts 67 | end 68 | 69 | test "to_s returns message content as string" do 70 | prompt = Prompt.new(message: "Test message") 71 | assert_equal "Test message", prompt.to_s 72 | end 73 | 74 | test "to_h returns hash representation of prompt" do 75 | instructions = Message.new(content: "Test instructions", role: :system) 76 | message = Message.new(content: "Test message") 77 | prompt = Prompt.new( 78 | actions: [ "action1" ], 79 | action_choice: "action1", 80 | instructions: instructions.content, 81 | message: message, 82 | messages: [], 83 | headers: { "Header-Key" => "Header-Value" }, 84 | context: [ "context1" ] 85 | ) 86 | expected_hash = { 87 | actions: [ "action1" ], 88 | action: "action1", 89 | instructions: instructions.content, 90 | message: message.to_h, 91 | messages: [ instructions.to_h, message.to_h ], 92 | headers: { "Header-Key" => "Header-Value" }, 93 | context: [ "context1" ] 94 | } 95 | 96 | assert_equal expected_hash, prompt.to_h 97 | end 98 | 99 | test "add_part adds a message to parts and updates message" do 100 | message = Message.new(content: "Part message", content_type: "text/plain") 101 | prompt = Prompt.new(content_type: "text/plain") 102 | 103 | prompt.add_part(message) 104 | 105 | assert_equal message, prompt.message 106 | assert_includes prompt.parts, prompt.context 107 | end 108 | 109 | test "multipart? returns true if parts are present" do 110 | prompt = Prompt.new 111 | assert_not prompt.multipart? 112 | 113 | prompt.add_part(Message.new(content: "Part message")) 114 | assert prompt.multipart? 115 | end 116 | 117 | test "headers method merges new headers" do 118 | prompt = Prompt.new(headers: { "Existing-Key" => "Existing-Value" }) 119 | prompt.headers("New-Key" => "New-Value") 120 | 121 | expected_headers = { "Existing-Key" => "Existing-Value", "New-Key" => "New-Value" } 122 | assert_equal expected_headers, prompt.instance_variable_get(:@headers) 123 | end 124 | 125 | test "set_messages adds system message if instructions are present" do 126 | prompt = Prompt.new(instructions: "System instructions") 127 | assert_equal 1, prompt.messages.size 128 | assert_equal "System instructions", prompt.messages.first.content 129 | assert_equal :system, prompt.messages.first.role 130 | end 131 | 132 | test "set_message creates a user message from string" do 133 | prompt = Prompt.new(message: "User message") 134 | assert_equal "User message", prompt.message.content 135 | assert_equal :user, prompt.message.role 136 | end 137 | 138 | test "set_message creates a user message from body if message content is blank" do 139 | prompt = Prompt.new(body: "Body content", message: Message.new(content: "")) 140 | assert_equal "Body content", prompt.message.content 141 | assert_equal :user, prompt.message.role 142 | end 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /test/support_agent_test.rb: -------------------------------------------------------------------------------- 1 | # test/support_agent_test.rb 2 | require "test_helper" 3 | 4 | class SupportAgentTest < ActiveSupport::TestCase 5 | test "it renders a prompt with an empty message using the Application Agent's text_prompt" do 6 | assert_equal "", SupportAgent.text_prompt.message.content 7 | end 8 | 9 | test "it renders a text_prompt generates a response with a tool call and performs the requested actions" do 10 | VCR.use_cassette("support_agent_text_prompt_tool_call_response") do 11 | message = "Show me a cat" 12 | prompt = SupportAgent.with(message: message).text_prompt 13 | response = prompt.generate_now 14 | assert_equal message, SupportAgent.with(message: message).text_prompt.message.content 15 | assert_equal 4, response.prompt.messages.size 16 | assert_equal :system, response.prompt.messages[0].role 17 | assert_equal :user, response.prompt.messages[1].role 18 | assert_equal :assistant, response.prompt.messages[2].role 19 | assert_equal :tool, response.prompt.messages[3].role 20 | end 21 | end 22 | 23 | test "it generates a sematic description for vector embeddings" do 24 | VCR.use_cassette("support_agent_tool_call") do 25 | message = "Show me a cat" 26 | prompt = SupportAgent.with(message: message).text_prompt 27 | response = prompt.generate_now 28 | assert_equal message, SupportAgent.with(message: message).text_prompt.message.content 29 | assert_equal 4, response.prompt.messages.size 30 | assert_equal :system, response.prompt.messages[0].role 31 | assert_equal :user, response.prompt.messages[1].role 32 | assert_equal :assistant, response.prompt.messages[2].role 33 | assert_equal :tool, response.prompt.messages[3].role 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | 4 | require_relative "../test/dummy/config/environment" 5 | ActiveRecord::Migrator.migrations_paths = [ File.expand_path("../test/dummy/db/migrate", __dir__) ] 6 | require "rails/test_help" 7 | require "vcr" 8 | 9 | VCR.configure do |config| 10 | config.cassette_library_dir = "fixtures/vcr_cassettes" 11 | config.hook_into :webmock 12 | config.filter_sensitive_data("") { Rails.application.credentials.dig(:openai, :api_key) } 13 | config.filter_sensitive_data("") { Rails.application.credentials.dig(:open_router, :api_key) } 14 | end 15 | 16 | # Load fixtures from the engine 17 | if ActiveSupport::TestCase.respond_to?(:fixture_paths=) 18 | ActiveSupport::TestCase.fixture_paths = [ File.expand_path("fixtures", __dir__) ] 19 | ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths 20 | ActiveSupport::TestCase.file_fixture_path = File.expand_path("fixtures", __dir__) + "/files" 21 | ActiveSupport::TestCase.fixtures :all 22 | end 23 | --------------------------------------------------------------------------------