action_ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module ActionAI
6
+ # = Action AI \ExecutionJob
7
+ #
8
+ # The +ActionAI::ExecutionJob+ class is used when you
9
+ # want to execute AI-powered jobs outside of the request-response cycle. It supports
10
+ # executing either parameterized or normal actions.
11
+ #
12
+ # Exceptions are rescued and handled by the agent class.
13
+ class ExecutionJob < ActiveJob::Base # :nodoc:
14
+ queue_as do
15
+ arguments.first.constantize
16
+ .execute_later_queue_name
17
+ end
18
+
19
+ rescue_from StandardError, with: :handle_exception_with_agent_class
20
+
21
+ def perform(agent, action, args: [], kwargs: {}, params: nil)
22
+ agent.constantize
23
+ .then { params ? it.with(params) : it }
24
+ .public_send(action, *args, **kwargs)
25
+ .run
26
+ end
27
+
28
+ private
29
+ # "Deserialize" the agent class name by hand in case another argument
30
+ # (like a Global ID reference) raised DeserializationError.
31
+ def agent_class
32
+ [@serialized_arguments, arguments]
33
+ .filter_map { Array(it).first }
34
+ .first&.constantize
35
+ end
36
+
37
+ def handle_exception_with_agent_class(exception)
38
+ agent_class&.handle_exception exception or
39
+ raise exception
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module ActionAI
6
+ # = Action AI \Interaction
7
+ #
8
+ # The +ActionAI::Interaction+ class is used by
9
+ # ActionAI::Agent when creating a new agent.
10
+ # <tt>Interaction</tt> is a wrapper (+Delegator+ subclass) around a lazy
11
+ # created +RubyLLM::Message+. You can get direct access to the
12
+ # +RubyLLM::Message+ or schedule the job to be executed
13
+ # through Active Job.
14
+ #
15
+ # Generator.code(task) # an ActionAI::Interaction object
16
+ # Generator.code(task).content # executes and returns the result
17
+ # Generator.code(task).later # enqueue execution as a job through Active Job
18
+ # Generator.code(task).message # a RubyLLM::Message object
19
+ class Interaction < Delegator
20
+ def initialize(agent_class, action, *args) # :nodoc:
21
+ @agent_class, @action, @args = agent_class, action, args
22
+
23
+ # The AI interaction is only processed if we try to call any methods on it.
24
+ # Typical usage will leave it unloaded and call +later+.
25
+ @processed_agent = nil
26
+ @message = nil
27
+ end
28
+ ruby2_keywords(:initialize)
29
+
30
+ # Method calls are delegated to the RubyLLM::Message that's ready to execute.
31
+ def __getobj__ # :nodoc:
32
+ @message ||= processed_agent.handle_exceptions do
33
+ processed_agent.run_callbacks(:execution) do
34
+ processed_agent.message
35
+ end
36
+ end.presence
37
+ end
38
+
39
+ # Unused except for delegator internals (dup, marshalling).
40
+ def __setobj__(message) # :nodoc:
41
+ @message = message
42
+ end
43
+
44
+ # Returns the resulting RubyLLM::Message
45
+ def message
46
+ __getobj__
47
+ end
48
+
49
+ # Was the delegate loaded, causing the action to be processed?
50
+ def processed?
51
+ @processed_agent || @message
52
+ end
53
+
54
+ def run = message
55
+
56
+ # Enqueues the action to be executed through Active Job.
57
+ #
58
+ # Generator.code(task).later
59
+ # Generator.code(task).later(wait: 1.hour)
60
+ # Generator.code(task).later(wait_until: 10.hours.from_now)
61
+ # Generator.code(task).later(priority: 10)
62
+ #
63
+ # Options:
64
+ #
65
+ # * <tt>:wait</tt> - Enqueue the action to be executed with a delay.
66
+ # * <tt>:wait_until</tt> - Enqueue the action to be executed at (after) a specific date / time.
67
+ # * <tt>:queue</tt> - Enqueue the action on the specified queue.
68
+ # * <tt>:priority</tt> - Enqueues the action with the specified priority
69
+ #
70
+ # By default, the action will be enqueued using ActionAI::ExecutionJob on
71
+ # the default queue. Agent classes can customize the queue name used for the default
72
+ # job by assigning a +execute_later_queue_name+ class variable, or provide a custom job
73
+ # by assigning a +execution_job+. When a custom job is used, it controls the queue name.
74
+ #
75
+ # class CostlyAgent < ApplicationAI
76
+ # self.execution_job = CostlyExecutionJob
77
+ # end
78
+ def later(...) = enqueue_execution(...)
79
+
80
+ private
81
+ # Returns the processed Agent instance. We keep this instance
82
+ # on hand so we can run callbacks and delegate exception handling to it.
83
+ def processed_agent
84
+ @processed_agent ||= @agent_class.new.tap do
85
+ it.process @action, *@args
86
+ end
87
+ end
88
+
89
+ def enqueue_execution(...)
90
+ if processed?
91
+ ::Kernel.raise "You've used the AI agent before asking to " \
92
+ "call it later, so you may have made local changes that would " \
93
+ "be silently lost if we enqueued a job to execute it. Why? Only " \
94
+ "the agent method *arguments* are passed with the execution job! " \
95
+ "Do not use the AI agent in any way if you mean to call it " \
96
+ "later. Workarounds: 1. don't touch the agent before calling " \
97
+ "#later, 2. only touch the agent *within your agent " \
98
+ "method*, or 3. use a custom Active Job instead of #later."
99
+ else
100
+ @agent_class.execution_job.set(...).perform_later(
101
+ @agent_class.name, @action.to_s, args: @args)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/log_subscriber"
4
+
5
+ module ActionAI
6
+ # = Action AI \LogSubscriber
7
+ #
8
+ # Implements the ActiveSupport::LogSubscriber for logging notifications when
9
+ # a prompt is executed.
10
+ class LogSubscriber < ActiveSupport::LogSubscriber
11
+ # A prompt was processed.
12
+ def process(event)
13
+ debug do
14
+ agent = event.payload[:agent]
15
+ action = event.payload[:action]
16
+ "#{agent}##{action}: executed prompt in #{event.duration.round(1)}ms"
17
+ end
18
+ end
19
+ subscribe_log_level :process, :debug
20
+
21
+ # Use the logger configured for ActionAI::Agent.
22
+ def logger
23
+ ActionAI::Agent.logger
24
+ end
25
+ end
26
+ end
27
+
28
+ ActionAI::LogSubscriber.attach_to :action_ai
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAI
4
+ # = Action AI \Parameterized
5
+ #
6
+ # Provides the option to parameterize AI agents in order to share instance variable
7
+ # setup, processing, and common defaults.
8
+ #
9
+ # Consider this example that does not use parameterization:
10
+ #
11
+ # class InvitationAgent < ApplicationAI
12
+ # def account_invitation(inviter, invitee)
13
+ # @account = inviter.account
14
+ # @inviter = inviter
15
+ # @invitee = invitee
16
+ # end
17
+ #
18
+ # def project_invitation(project, inviter, invitee)
19
+ # @account = inviter.account
20
+ # @project = project
21
+ # @inviter = inviter
22
+ # @invitee = invitee
23
+ # @summarizer = ProjectInvitationSummarizer.new(@project.bucket)
24
+ # end
25
+ #
26
+ # def bulk_project_invitation(projects, inviter, invitee)
27
+ # @account = inviter.account
28
+ # @projects = projects.sort_by(&:name)
29
+ # @inviter = inviter
30
+ # @invitee = invitee
31
+ # end
32
+ # end
33
+ #
34
+ # InvitationAgent.account_invitation(person_a, person_b).later
35
+ #
36
+ # Using parameterized agents, this can be rewritten as:
37
+ #
38
+ # class InvitationAgent < ApplicationAI
39
+ # before_action { @inviter, @invitee = params[:inviter], params[:invitee] }
40
+ # before_action { @account = params[:inviter].account }
41
+ #
42
+ # def account_invitation
43
+ # end
44
+ #
45
+ # def project_invitation
46
+ # @project = params[:project]
47
+ # @summarizer = ProjectInvitationSummarizer.new(@project.bucket)
48
+ # end
49
+ #
50
+ # def bulk_project_invitation
51
+ # @projects = params[:projects].sort_by(&:name)
52
+ # end
53
+ # end
54
+ #
55
+ # InvitationAgent.with(inviter: person_a, invitee: person_b).account_invitation.later
56
+ module Parameterized
57
+ extend ActiveSupport::Concern
58
+
59
+ included do
60
+ attr_writer :params
61
+
62
+ def params
63
+ @params ||= {}
64
+ end
65
+ end
66
+
67
+ module ClassMethods
68
+ # Provide the parameters to the agent in order to use them in the instance methods and callbacks.
69
+ #
70
+ # InvitationAgent.with(inviter: person_a, invitee: person_b).account_invitation.later
71
+ #
72
+ # See Parameterized documentation for full example.
73
+ def with(params)
74
+ ActionAI::Parameterized::Agent.new(self, params)
75
+ end
76
+ end
77
+
78
+ class Agent # :nodoc:
79
+ def initialize(agent, params)
80
+ @agent, @params = agent, params
81
+ end
82
+
83
+ private
84
+ def method_missing(method_name, ...)
85
+ if @agent.action_methods.include?(method_name.name)
86
+ ActionAI::Parameterized::PromptExecution.new(@agent, method_name, @params, ...)
87
+ else
88
+ super
89
+ end
90
+ end
91
+
92
+ def respond_to_missing?(method, include_all = false)
93
+ @agent.respond_to?(method, include_all)
94
+ end
95
+ end
96
+
97
+ class PromptExecution < ActionAI::Interaction # :nodoc:
98
+ def initialize(agent_class, action, params, ...)
99
+ super(agent_class, action, ...)
100
+ @params = params
101
+ end
102
+
103
+ private
104
+ def processed_agent
105
+ @processed_agent ||= @agent_class.new.tap do |agent|
106
+ agent.params = @params
107
+ agent.process @action, *@args
108
+ end
109
+ end
110
+
111
+ def enqueue_execution(...)
112
+ if processed?
113
+ super
114
+ else
115
+ @agent_class.execution_job.set(...).perform_later(
116
+ @agent_class.name, @action.to_s, params: @params, args: @args)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/descendants_tracker"
4
+
5
+ module ActionAI
6
+ module Previews # :nodoc:
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ # Add the location of AI agent previews through app configuration:
11
+ #
12
+ # config.action_ai.preview_paths << "#{Rails.root}/lib/ai/previews"
13
+ #
14
+ mattr_accessor :preview_paths, instance_writer: false, default: []
15
+
16
+ # Enable or disable prompt previews through app configuration:
17
+ #
18
+ # config.action_ai.show_previews = true
19
+ #
20
+ # Defaults to +true+ for development environment
21
+ #
22
+ mattr_accessor :show_previews, instance_writer: false
23
+ end
24
+ end
25
+
26
+ class Preview
27
+ extend ActiveSupport::DescendantsTracker
28
+
29
+ attr_reader :params
30
+
31
+ def initialize(params = {})
32
+ @params = params
33
+ end
34
+
35
+ class << self
36
+ # Returns all agent preview classes.
37
+ def all
38
+ load_previews if descendants.empty?
39
+ descendants.sort_by { it.name.titleize }
40
+ end
41
+
42
+ # Returns the message object for the given action name.
43
+ def call(action, params = {})
44
+ preview = new(params)
45
+ message = preview.public_send(action)
46
+ message
47
+ end
48
+
49
+ # Returns all of the available action previews.
50
+ def actions
51
+ public_instance_methods(false).map(&:to_s).sort
52
+ end
53
+
54
+ # Returns +true+ if the action exists.
55
+ def action_exists?(action)
56
+ actions.include?(action)
57
+ end
58
+
59
+ # Returns +true+ if the preview exists.
60
+ def exists?(preview)
61
+ all.any? { |p| p.preview_name == preview }
62
+ end
63
+
64
+ # Find an agent preview by its underscored class name.
65
+ def find(preview)
66
+ all.find { |p| p.preview_name == preview }
67
+ end
68
+
69
+ # Returns the underscored name of the agent preview without the suffix.
70
+ def preview_name
71
+ name.delete_suffix("Preview").underscore
72
+ end
73
+
74
+ private
75
+ def load_previews
76
+ preview_paths.each do |preview_path|
77
+ Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require file }
78
+ end
79
+ end
80
+
81
+ def preview_paths
82
+ Agent.preview_paths
83
+ end
84
+
85
+ def show_previews
86
+ Agent.show_previews
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAI
4
+ # = Action AI \PromptHelper
5
+ #
6
+ # Provides helper methods for ActionAI::Agent that can be used for easily
7
+ # formatting messages, accessing agent or message instances, and the
8
+ # attachments list.
9
+ module PromptHelper
10
+ # Take the text and format it, indented two spaces for each line, and
11
+ # wrapped at 72 columns:
12
+ #
13
+ # text = <<-TEXT
14
+ # This is
15
+ # the paragraph.
16
+ #
17
+ # * item1 * item2
18
+ # TEXT
19
+ #
20
+ # block_format text
21
+ # # => " This is the paragraph.\n\n * item1\n * item2\n"
22
+ def block_format(text)
23
+ formatted = text.split(/\n\r?\n/).collect { |paragraph|
24
+ format_paragraph(paragraph)
25
+ }.join("\n\n")
26
+
27
+ # Make list points stand on their own line
28
+ output = +""
29
+ splits = formatted.split(/(\*+|\#+)/)
30
+ while line = splits.shift
31
+ if line.start_with?("*", "#") && splits.first&.start_with?(" ")
32
+ output.chomp!(" ") while output.end_with?(" ")
33
+ output << " #{line} #{splits.shift.strip}\n"
34
+ else
35
+ output << line
36
+ end
37
+ end
38
+
39
+ output
40
+ end
41
+
42
+ # Access the agent instance.
43
+ def agent
44
+ @_controller
45
+ end
46
+
47
+ # Access the message instance.
48
+ def message
49
+ @_message
50
+ end
51
+
52
+ # Access the message attachments list.
53
+ def attachments
54
+ agent.attachments
55
+ end
56
+
57
+ # Returns +text+ wrapped at +len+ columns and indented +indent+ spaces.
58
+ # By default column length +len+ equals 72 characters and indent
59
+ # +indent+ equal two spaces.
60
+ #
61
+ # my_text = 'Here is a sample text with more than 40 characters'
62
+ #
63
+ # format_paragraph(my_text, 25, 4)
64
+ # # => " Here is a sample text with\n more than 40 characters"
65
+ def format_paragraph(text, len = 72, indent = 2)
66
+ sentences = [[]]
67
+
68
+ text.split.each do |word|
69
+ if sentences.first.present? && (sentences.last + [word]).join(" ").length > len
70
+ sentences << [word]
71
+ else
72
+ sentences.last << word
73
+ end
74
+ end
75
+
76
+ indentation = " " * indent
77
+ sentences.map! { |sentence|
78
+ "#{indentation}#{sentence.join(' ')}"
79
+ }.join "\n"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAI
4
+ module QueuedExecution
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :execution_job, default: ::ActionAI::ExecutionJob
9
+ class_attribute :execute_later_queue_name, default: :ai_agents
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job/railtie"
4
+ require "action_ai"
5
+ require "rails"
6
+ require "abstract_controller/railties/routes_helpers"
7
+
8
+ module ActionAI
9
+ class Railtie < Rails::Railtie # :nodoc:
10
+ config.action_ai = ActiveSupport::OrderedOptions.new
11
+ config.action_ai.preview_paths = []
12
+ config.eager_load_namespaces << ActionAI
13
+
14
+ initializer "action_ai.deprecator", before: :load_environment_config do |app|
15
+ app.deprecators[:action_ai] = ActionAI.deprecator
16
+ end
17
+
18
+ initializer "action_ai.logger" do
19
+ ActiveSupport.on_load(:action_ai) { self.logger ||= Rails.logger }
20
+ end
21
+
22
+ initializer "action_ai.set_configs" do |app|
23
+ paths = app.config.paths
24
+ options = app.config.action_ai
25
+
26
+ options.assets_dir ||= paths["public"].first
27
+ options.javascripts_dir ||= paths["public/javascripts"].first
28
+ options.stylesheets_dir ||= paths["public/stylesheets"].first
29
+ options.show_previews = Rails.env.development? if options.show_previews.nil?
30
+ options.cache_store ||= Rails.cache
31
+ options.preview_paths |= ["#{Rails.root}/test/ai/previews"]
32
+
33
+ # make sure readers methods get compiled
34
+ options.asset_host ||= app.config.asset_host
35
+ options.relative_url_root ||= app.config.relative_url_root
36
+
37
+ ActiveSupport.on_load(:action_ai) do
38
+ include AbstractController::UrlFor
39
+ extend ::AbstractController::Railties::RoutesHelpers.with(app.routes, false)
40
+ include app.routes.mounted_helpers
41
+
42
+ self.preview_paths |= options[:preview_paths]
43
+ self.view_paths = ["#{Rails.root}/app/ai/prompts"]
44
+
45
+ if execution_job = options.delete(:execution_job)
46
+ self.execution_job = execution_job.constantize
47
+ end
48
+
49
+ options.each { |k, v| send("#{k}=", v) }
50
+ end
51
+
52
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
53
+ include ActionAI::TestHelper
54
+ end
55
+ end
56
+
57
+ initializer "action_ai.set_autoload_paths", before: :set_autoload_paths do |app|
58
+ options = app.config.action_ai
59
+ app.config.paths["test/ai/prompts/previews"].concat(options.preview_paths)
60
+ end
61
+
62
+ config.after_initialize do |app|
63
+ options = app.config.action_ai
64
+
65
+ if options.show_previews
66
+ app.routes.prepend do
67
+ get "/rails/ai/agents" => "rails/ai/agents#index", internal: true
68
+ get "/rails/ai/agents/download/*path" => "rails/ai/agents#download", internal: true
69
+ get "/rails/ai/agents/*path" => "rails/ai/agents#preview", internal: true
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAI # :nodoc:
4
+ # = Action AI \Rescuable
5
+ #
6
+ # Provides
7
+ # {rescue_from}[rdoc-ref:ActiveSupport::Rescuable::ClassMethods#rescue_from]
8
+ # for AI agents. Wraps agent action processing and job execution to handle
9
+ # 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
+ def process(...)
28
+ handle_exceptions do
29
+ super
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/test_case"
4
+ require "rails-dom-testing"
5
+
6
+ module ActionAI
7
+ class NonInferrableAgentError < ::StandardError
8
+ def initialize(name)
9
+ super "Unable to determine the agent to test from #{name}. " \
10
+ "You'll need to specify it using tests YourAgent in your " \
11
+ "test case definition"
12
+ end
13
+ end
14
+
15
+ class TestCase < ActiveSupport::TestCase
16
+ module ClearTestInteractions
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ setup :clear_test_interactions
21
+ teardown :clear_test_interactions
22
+ end
23
+
24
+ private
25
+ def clear_test_interactions
26
+ if ActionAI.respond_to? :interactions
27
+ ActionAI.interactions.clear
28
+ end
29
+ end
30
+ end
31
+
32
+ module Behavior
33
+ extend ActiveSupport::Concern
34
+
35
+ include ActiveSupport::Testing::ConstantLookup
36
+ include TestHelper
37
+ include Rails::Dom::Testing::Assertions::SelectorAssertions
38
+ include Rails::Dom::Testing::Assertions::DomAssertions
39
+
40
+ included do
41
+ class_attribute :_agent_class
42
+ setup :initialize_test_interactions
43
+ setup :set_expected_message
44
+ ActiveSupport.run_load_hooks(:action_ai_test_case, self)
45
+ end
46
+
47
+ module ClassMethods
48
+ def tests(agent)
49
+ case agent
50
+ when String, Symbol
51
+ self._agent_class = agent.to_s.camelize.constantize
52
+ when Module
53
+ self._agent_class = agent
54
+ else
55
+ raise NonInferrableAgentError.new(agent)
56
+ end
57
+ end
58
+
59
+ def agent_class
60
+ _agent_class or
61
+ tests determine_default_agent(name)
62
+ end
63
+
64
+ def determine_default_agent(name)
65
+ determine_constant_from_test_name(name) do |constant|
66
+ Class === constant && constant < ActionAI::Agent
67
+ end or
68
+ raise NonInferrableAgentError.new(name)
69
+ end
70
+ end
71
+
72
+ # Reads the fixture file for the given agent.
73
+ #
74
+ # This is useful when testing agents by being able to write the body of
75
+ # a prompt inside a fixture. See the testing guide for a concrete example:
76
+ # https://guides.rubyonrails.org/testing.html#revenge-of-the-fixtures
77
+ def read_fixture(action)
78
+ IO.readlines(File.join(Rails.root, "test", "fixtures", self.class.agent_class.name.underscore, action))
79
+ end
80
+
81
+ private
82
+ def initialize_test_interactions
83
+ ActionAI.interactions.clear
84
+ end
85
+
86
+ def set_expected_message
87
+ @expected = RubyLLM::Message.new(
88
+ role: :system,
89
+ content: "",
90
+ )
91
+ end
92
+ end
93
+
94
+ include Behavior
95
+ end
96
+ end