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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/MIT-LICENSE +21 -0
- data/README.rdoc +107 -0
- data/lib/action_ai/agent.rb +465 -0
- data/lib/action_ai/callbacks.rb +31 -0
- data/lib/action_ai/deprecator.rb +7 -0
- data/lib/action_ai/execution_job.rb +42 -0
- data/lib/action_ai/interaction.rb +105 -0
- data/lib/action_ai/log_subscriber.rb +28 -0
- data/lib/action_ai/parameterized.rb +121 -0
- data/lib/action_ai/preview.rb +90 -0
- data/lib/action_ai/prompt_helper.rb +82 -0
- data/lib/action_ai/queued_execution.rb +12 -0
- data/lib/action_ai/railtie.rb +74 -0
- data/lib/action_ai/rescuable.rb +33 -0
- data/lib/action_ai/test_case.rb +96 -0
- data/lib/action_ai/test_helper.rb +272 -0
- data/lib/action_ai/version.rb +5 -0
- data/lib/action_ai.rb +72 -0
- data/lib/rails/generators/ai/USAGE +20 -0
- data/lib/rails/generators/ai/ai_generator.rb +32 -0
- data/lib/rails/generators/ai/templates/agent.rb.tt +11 -0
- data/lib/rails/generators/ai/templates/application_agent.rb.tt +5 -0
- data/lib/ruby_llm/providers/test/echo.rb +47 -0
- data/lib/ruby_llm/providers/test.rb +40 -0
- data/lib/ruby_llm/tester.rb +40 -0
- metadata +169 -0
|
@@ -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
|