mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-03-08 18:29:32 +00:00
Introduces custom AI tools functionality. 1. Why it was added: The PR adds the ability to create, manage, and use custom AI tools within the Discourse AI system. This feature allows for more flexibility and extensibility in the AI capabilities of the platform. 2. What it does: - Introduces a new `AiTool` model for storing custom AI tools - Adds CRUD (Create, Read, Update, Delete) operations for AI tools - Implements a tool runner system for executing custom tool scripts - Integrates custom tools with existing AI personas - Provides a user interface for managing custom tools in the admin panel 3. Possible use cases: - Creating custom tools for specific tasks or integrations (stock quotes, currency conversion etc...) - Allowing administrators to add new functionalities to AI assistants without modifying core code - Implementing domain-specific tools for particular communities or industries 4. Code structure: The PR introduces several new files and modifies existing ones: a. Models: - `app/models/ai_tool.rb`: Defines the AiTool model - `app/serializers/ai_custom_tool_serializer.rb`: Serializer for AI tools b. Controllers: - `app/controllers/discourse_ai/admin/ai_tools_controller.rb`: Handles CRUD operations for AI tools c. Views and Components: - New Ember.js components for tool management in the admin interface - Updates to existing AI persona management components to support custom tools d. Core functionality: - `lib/ai_bot/tool_runner.rb`: Implements the custom tool execution system - `lib/ai_bot/tools/custom.rb`: Defines the custom tool class e. Routes and configurations: - Updates to route configurations to include new AI tool management pages f. Migrations: - `db/migrate/20240618080148_create_ai_tools.rb`: Creates the ai_tools table g. Tests: - New test files for AI tool functionality and integration The PR integrates the custom tools system with the existing AI persona framework, allowing personas to use both built-in and custom tools. It also includes safety measures such as timeouts and HTTP request limits to prevent misuse of custom tools. Overall, this PR significantly enhances the flexibility and extensibility of the Discourse AI system by allowing administrators to create and manage custom AI tools tailored to their specific needs. Co-authored-by: Martin Brennan <martin@discourse.org>
284 lines
7.3 KiB
Ruby
284 lines
7.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module DiscourseAi
|
|
module AiBot
|
|
module Tools
|
|
class Tool
|
|
# Why 30 mega bytes?
|
|
# This general limit is mainly a security feature to avoid tools
|
|
# forcing infinite downloads or causing memory exhaustion.
|
|
# The limit is somewhat arbitrary and can be increased in future if needed.
|
|
MAX_RESPONSE_BODY_LENGTH = 30.megabyte
|
|
|
|
class << self
|
|
def signature
|
|
raise NotImplemented
|
|
end
|
|
|
|
def name
|
|
raise NotImplemented
|
|
end
|
|
|
|
def custom?
|
|
false
|
|
end
|
|
|
|
def accepted_options
|
|
[]
|
|
end
|
|
|
|
def option(name, type:)
|
|
Option.new(tool: self, name: name, type: type)
|
|
end
|
|
|
|
def help
|
|
I18n.t("discourse_ai.ai_bot.tool_help.#{signature[:name]}")
|
|
end
|
|
|
|
def custom_system_message
|
|
nil
|
|
end
|
|
end
|
|
|
|
attr_accessor :custom_raw
|
|
attr_reader :tool_call_id, :persona_options, :bot_user, :llm, :context, :parameters
|
|
|
|
def initialize(
|
|
parameters,
|
|
tool_call_id: "",
|
|
persona_options: {},
|
|
bot_user:,
|
|
llm:,
|
|
context: {}
|
|
)
|
|
@parameters = parameters
|
|
@tool_call_id = tool_call_id
|
|
@persona_options = persona_options
|
|
@bot_user = bot_user
|
|
@llm = llm
|
|
@context = context
|
|
end
|
|
|
|
def name
|
|
self.class.name
|
|
end
|
|
|
|
def summary
|
|
I18n.t("discourse_ai.ai_bot.tool_summary.#{name}")
|
|
end
|
|
|
|
def details
|
|
I18n.t("discourse_ai.ai_bot.tool_description.#{name}", description_args)
|
|
end
|
|
|
|
def help
|
|
I18n.t("discourse_ai.ai_bot.tool_help.#{name}")
|
|
end
|
|
|
|
def options
|
|
result = HashWithIndifferentAccess.new
|
|
self.class.accepted_options.each do |option|
|
|
val = @persona_options[option.name]
|
|
if val
|
|
case option.type
|
|
when :boolean
|
|
val = (val.to_s == "true")
|
|
when :integer
|
|
val = val.to_i
|
|
end
|
|
result[option.name] = val
|
|
end
|
|
end
|
|
result
|
|
end
|
|
|
|
def chain_next_response?
|
|
true
|
|
end
|
|
|
|
def standalone?
|
|
false
|
|
end
|
|
|
|
protected
|
|
|
|
def fetch_default_branch(repo)
|
|
api_url = "https://api.github.com/repos/#{repo}"
|
|
|
|
response_code = "unknown error"
|
|
repo_data = nil
|
|
|
|
send_http_request(
|
|
api_url,
|
|
headers: {
|
|
"Accept" => "application/vnd.github.v3+json",
|
|
},
|
|
authenticate_github: true,
|
|
) do |response|
|
|
response_code = response.code
|
|
if response_code == "200"
|
|
begin
|
|
repo_data = JSON.parse(read_response_body(response))
|
|
rescue JSON::ParserError
|
|
response_code = "500 - JSON parse error"
|
|
end
|
|
end
|
|
end
|
|
|
|
response_code == "200" ? repo_data["default_branch"] : "main"
|
|
end
|
|
|
|
def send_http_request(
|
|
url,
|
|
headers: {},
|
|
authenticate_github: false,
|
|
follow_redirects: false,
|
|
method: :get,
|
|
body: nil,
|
|
&blk
|
|
)
|
|
self.class.send_http_request(
|
|
url,
|
|
headers: headers,
|
|
authenticate_github: authenticate_github,
|
|
follow_redirects: follow_redirects,
|
|
method: method,
|
|
body: body,
|
|
&blk
|
|
)
|
|
end
|
|
|
|
def self.send_http_request(
|
|
url,
|
|
headers: {},
|
|
authenticate_github: false,
|
|
follow_redirects: false,
|
|
method: :get,
|
|
body: nil
|
|
)
|
|
raise "Expecting caller to use a block" if !block_given?
|
|
|
|
uri = nil
|
|
url = UrlHelper.normalized_encode(url)
|
|
uri =
|
|
begin
|
|
URI.parse(url)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
|
|
return if !uri
|
|
|
|
if follow_redirects
|
|
fd =
|
|
FinalDestination.new(
|
|
url,
|
|
validate_uri: true,
|
|
max_redirects: 5,
|
|
follow_canonical: true,
|
|
)
|
|
|
|
uri = fd.resolve
|
|
end
|
|
|
|
return if uri.blank?
|
|
|
|
request = nil
|
|
if method == :get
|
|
request = FinalDestination::HTTP::Get.new(uri)
|
|
elsif method == :post
|
|
request = FinalDestination::HTTP::Post.new(uri)
|
|
end
|
|
|
|
raise ArgumentError, "Invalid method: #{method}" if !request
|
|
|
|
request.body = body if body
|
|
|
|
request["User-Agent"] = DiscourseAi::AiBot::USER_AGENT
|
|
headers.each { |k, v| request[k] = v }
|
|
if authenticate_github && SiteSetting.ai_bot_github_access_token.present?
|
|
request["Authorization"] = "Bearer #{SiteSetting.ai_bot_github_access_token}"
|
|
end
|
|
|
|
FinalDestination::HTTP.start(uri.hostname, uri.port, use_ssl: uri.port != 80) do |http|
|
|
http.request(request) { |response| yield response }
|
|
end
|
|
end
|
|
|
|
def self.read_response_body(response, max_length: nil)
|
|
max_length ||= MAX_RESPONSE_BODY_LENGTH
|
|
|
|
body = +""
|
|
response.read_body do |chunk|
|
|
body << chunk
|
|
break if body.bytesize > max_length
|
|
end
|
|
|
|
if body.bytesize > max_length
|
|
body[0...max_length].scrub
|
|
else
|
|
body.scrub
|
|
end
|
|
end
|
|
|
|
def read_response_body(response, max_length: nil)
|
|
self.class.read_response_body(response, max_length: max_length)
|
|
end
|
|
|
|
def truncate(text, llm:, percent_length: nil, max_length: nil)
|
|
if !percent_length && !max_length
|
|
raise ArgumentError, "You must provide either percent_length or max_length"
|
|
end
|
|
|
|
target = llm.max_prompt_tokens
|
|
target = (target * percent_length).to_i if percent_length
|
|
|
|
if max_length
|
|
target = max_length if target > max_length
|
|
end
|
|
|
|
llm.tokenizer.truncate(text, target)
|
|
end
|
|
|
|
def accepted_options
|
|
[]
|
|
end
|
|
|
|
def option(name, type:)
|
|
Option.new(tool: self, name: name, type: type)
|
|
end
|
|
|
|
def description_args
|
|
{}
|
|
end
|
|
|
|
def format_results(rows, column_names = nil, args: nil)
|
|
rows = rows&.map { |row| yield row } if block_given?
|
|
|
|
if !column_names
|
|
index = -1
|
|
column_indexes = {}
|
|
|
|
rows =
|
|
rows&.map do |data|
|
|
new_row = []
|
|
data.each do |key, value|
|
|
found_index = column_indexes[key.to_s] ||= (index += 1)
|
|
new_row[found_index] = value
|
|
end
|
|
new_row
|
|
end
|
|
column_names = column_indexes.keys
|
|
end
|
|
|
|
# this is not the most efficient format
|
|
# however this is needed cause GPT 3.5 / 4 was steered using JSON
|
|
result = { column_names: column_names, rows: rows }
|
|
result[:args] = args if args
|
|
result
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|