+ {{#each parameter.enumValues as |enumValue enumIndex|}}
+
+
+
+
+ {{/each}}
+
+
+ {{/if}}
+
+ {{/each}}
+
+
+}
diff --git a/assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs b/assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs
new file mode 100644
index 00000000..b937380c
--- /dev/null
+++ b/assets/javascripts/discourse/components/modal/ai-tool-test-modal.gjs
@@ -0,0 +1,83 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { fn } from "@ember/helper";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import DButton from "discourse/components/d-button";
+import DModal from "discourse/components/d-modal";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import I18n from "discourse-i18n";
+import { jsonToHtml } from "../../lib/utilities";
+
+export default class AiToolTestModal extends Component {
+ @tracked testResult = null;
+ @tracked isLoading = false;
+ @tracked parameterValues = {};
+
+ @action
+ updateParameter(name, event) {
+ this.parameterValues[name] = event.target.value;
+ }
+
+ @action
+ async runTest() {
+ this.isLoading = true;
+ try {
+ const data = JSON.stringify({
+ ai_tool: this.args.model.tool,
+ parameters: this.parameterValues,
+ }); // required given this is a POST
+
+ const response = await ajax(
+ "/admin/plugins/discourse-ai/ai-tools/test.json",
+ {
+ type: "POST",
+ data,
+ contentType: "application/json",
+ }
+ );
+ this.testResult = jsonToHtml(response.output);
+ } catch (error) {
+ popupAjaxError(error);
+ } finally {
+ this.isLoading = false;
+ }
+ }
+
+
+
+ <:body>
+ {{#each @model.tool.parameters as |param|}}
+
+
+
+
+ {{/each}}
+ {{#if this.testResult}}
+
+
{{I18n.t "discourse_ai.tools.test_modal.result"}}
+
{{this.testResult}}
+
+ {{/if}}
+
+ <:footer>
+
+
+
+
+}
diff --git a/assets/javascripts/discourse/components/modal/debug-ai-modal.gjs b/assets/javascripts/discourse/components/modal/debug-ai-modal.gjs
index d18cb28e..5d0cdf69 100644
--- a/assets/javascripts/discourse/components/modal/debug-ai-modal.gjs
+++ b/assets/javascripts/discourse/components/modal/debug-ai-modal.gjs
@@ -11,6 +11,7 @@ import { clipboardCopy, escapeExpression } from "discourse/lib/utilities";
import i18n from "discourse-common/helpers/i18n";
import discourseLater from "discourse-common/lib/later";
import I18n from "discourse-i18n";
+import { jsonToHtml } from "../../lib/utilities";
export default class DebugAiModal extends Component {
@tracked info = null;
@@ -41,7 +42,7 @@ export default class DebugAiModal extends Component {
return this.info.raw_request_payload;
}
- return htmlSafe(this.jsonToHtml(parsed));
+ return jsonToHtml(parsed);
}
formattedResponse(response) {
@@ -52,35 +53,6 @@ export default class DebugAiModal extends Component {
return htmlSafe(safe);
}
- jsonToHtml(json) {
- let html = "
";
- for (let key in json) {
- if (!json.hasOwnProperty(key)) {
- continue;
- }
- html += "
";
- if (typeof json[key] === "object" && Array.isArray(json[key])) {
- html += `${escapeExpression(key)}: ${this.jsonToHtml(
- json[key]
- )}`;
- } else if (typeof json[key] === "object") {
- html += `${escapeExpression(
- key
- )}:
${this.jsonToHtml(json[key])}
`;
- } else {
- let value = json[key];
- if (typeof value === "string") {
- value = escapeExpression(value);
- value = value.replace(/\n/g, " ");
- }
- html += `${escapeExpression(key)}: ${value}`;
- }
- html += "
";
- }
- html += "
";
- return html;
- }
-
@action
copyRequest() {
this.copy(this.info.raw_request_payload);
diff --git a/assets/javascripts/discourse/lib/utilities.js b/assets/javascripts/discourse/lib/utilities.js
index bd407327..5833cd97 100644
--- a/assets/javascripts/discourse/lib/utilities.js
+++ b/assets/javascripts/discourse/lib/utilities.js
@@ -1,2 +1,37 @@
+import { htmlSafe } from "@ember/template";
+import { escapeExpression } from "discourse/lib/utilities";
+
export const IMAGE_MARKDOWN_REGEX =
/!\[(.*?)\|(\d{1,4}x\d{1,4})(,\s*\d{1,3}%)?(.*?)\]\((upload:\/\/.*?)\)(?!(.*`))/g;
+
+export function jsonToHtml(json) {
+ if (typeof json !== "object") {
+ return escapeExpression(json);
+ }
+ let html = "
";
+ for (let key in json) {
+ if (!json.hasOwnProperty(key)) {
+ continue;
+ }
+ html += "
";
+ if (typeof json[key] === "object" && Array.isArray(json[key])) {
+ html += `${escapeExpression(key)}: ${jsonToHtml(
+ json[key]
+ )}`;
+ } else if (typeof json[key] === "object") {
+ html += `${escapeExpression(key)}:
${jsonToHtml(
+ json[key]
+ )}
`;
+ } else {
+ let value = json[key];
+ if (typeof value === "string") {
+ value = escapeExpression(value);
+ value = value.replace(/\n/g, " ");
+ }
+ html += `${escapeExpression(key)}: ${value}`;
+ }
+ html += "
";
+ }
+ html += "
";
+ return htmlSafe(html);
+}
diff --git a/assets/javascripts/initializers/admin-plugin-configuration-nav.js b/assets/javascripts/initializers/admin-plugin-configuration-nav.js
index 51d88222..6fa22aca 100644
--- a/assets/javascripts/initializers/admin-plugin-configuration-nav.js
+++ b/assets/javascripts/initializers/admin-plugin-configuration-nav.js
@@ -12,13 +12,17 @@ export default {
withPluginApi("1.1.0", (api) => {
api.addAdminPluginConfigurationNav("discourse-ai", PLUGIN_NAV_MODE_TOP, [
+ {
+ label: "discourse_ai.llms.short_title",
+ route: "adminPlugins.show.discourse-ai-llms",
+ },
{
label: "discourse_ai.ai_persona.short_title",
route: "adminPlugins.show.discourse-ai-personas",
},
{
- label: "discourse_ai.llms.short_title",
- route: "adminPlugins.show.discourse-ai-llms",
+ label: "discourse_ai.tools.short_title",
+ route: "adminPlugins.show.discourse-ai-tools",
},
]);
});
diff --git a/assets/stylesheets/modules/ai-bot/common/ai-tools.scss b/assets/stylesheets/modules/ai-bot/common/ai-tools.scss
new file mode 100644
index 00000000..a7a9e60f
--- /dev/null
+++ b/assets/stylesheets/modules/ai-bot/common/ai-tools.scss
@@ -0,0 +1,79 @@
+.ai-tool-parameter {
+ margin-bottom: 2em;
+ padding: 1.5em;
+ border: 1px solid var(--primary-low);
+ border-radius: 3px;
+ background-color: var(--secondary-very-low);
+
+ .parameter-row {
+ display: flex;
+ align-items: center;
+ margin-bottom: 1em;
+
+ input[type="text"] {
+ flex-grow: 1;
+ margin-right: 1em;
+ }
+
+ label {
+ margin-right: 1em;
+ white-space: nowrap;
+ }
+ }
+
+ .parameter-enum-values {
+ margin-top: 0.5em;
+
+ .enum-value-row {
+ display: flex;
+ align-items: center;
+ margin-bottom: 0.5em;
+
+ input[type="text"] {
+ flex-grow: 1;
+ margin-right: 0.5em;
+ }
+ }
+ }
+}
+
+.ai-tool-editor {
+ max-width: 80%;
+ position: relative;
+ .ace-wrapper {
+ border: 1px solid var(--primary-low);
+ max-width: 100%;
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 500px;
+ .ace_editor {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ }
+ }
+}
+
+.ai-tool-test-modal {
+ &__test-result div {
+ ul {
+ padding-left: 1em;
+ }
+ }
+}
+
+.ai-tool-list-editor {
+ &__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0 0 1em 0;
+
+ h3 {
+ margin: 0;
+ }
+ }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 7d421e4d..fd7b2c77 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -183,6 +183,36 @@ en:
uploading: "Uploading..."
remove: "Remove upload"
+ tools:
+ short_title: "Tools"
+ new: "New Tool"
+ name: "Name"
+ name_help: "The unique name of the tool as used by the language model"
+ description: "Description"
+ description_help: "A clear description of the tool's purpose for the language model"
+ summary: "Summary"
+ summary_help: "Summary of tools purpose to be displayed to end users"
+ script: "Script"
+ parameters: "Parameters"
+ save: "Save"
+ add_parameter: "Add Parameter"
+ parameter_required: "Required"
+ parameter_enum: "Enum"
+ parameter_name: "Parameter Name"
+ parameter_description: "Parameter Description"
+ edit: "Edit"
+ test: "Run Test"
+ delete: "Delete"
+ saved: "Tool saved"
+ presets: "Select a preset..."
+ confirm_delete: Are you sure you want to delete this tool?
+ next:
+ title: "Next"
+ test_modal:
+ title: "Test AI Tool"
+ run: "Run Test"
+ result: "Test Result"
+
llms:
short_title: "LLMs"
no_llms: "No LLMs yet"
@@ -230,7 +260,7 @@ en:
google: "Google"
azure: "Azure"
ollama: "Ollama"
-
+
provider_fields:
access_key_id: "AWS Bedrock Access key ID"
region: "AWS Bedrock Region"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index d40f632f..06b9169d 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -142,6 +142,18 @@ en:
discourse_ai:
unknown_model: "Unknown AI model"
+ tools:
+ custom_name: "%{name} (custom)"
+ presets:
+ browse_web_jina:
+ name: "Browse web using jina.ai"
+ exchange_rate:
+ name: "Exchange rate"
+ stock_quote:
+ name: "Stock quote (AlphaVantage)"
+ empty_tool:
+ name: "Start from blank..."
+
ai_helper:
errors:
completion_request_failed: "Something went wrong while trying to provide suggestions. Please, try again."
diff --git a/config/routes.rb b/config/routes.rb
index 68446e4a..8f621eb1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -41,6 +41,13 @@ Discourse::Application.routes.draw do
path: "ai-personas",
controller: "discourse_ai/admin/ai_personas"
+ resources(
+ :ai_tools,
+ only: %i[index create show update destroy],
+ path: "ai-tools",
+ controller: "discourse_ai/admin/ai_tools",
+ ) { post :test, on: :collection }
+
post "/ai-personas/:id/create-user", to: "discourse_ai/admin/ai_personas#create_user"
post "/ai-personas/files/upload", to: "discourse_ai/admin/ai_personas#upload_file"
put "/ai-personas/:id/files/remove", to: "discourse_ai/admin/ai_personas#remove_file"
diff --git a/db/migrate/20240618080148_create_ai_tools.rb b/db/migrate/20240618080148_create_ai_tools.rb
new file mode 100644
index 00000000..30d84def
--- /dev/null
+++ b/db/migrate/20240618080148_create_ai_tools.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class CreateAiTools < ActiveRecord::Migration[7.0]
+ def change
+ create_table :ai_tools do |t|
+ t.string :name, null: false, max_length: 100, unique: true
+ t.string :description, null: false, max_length: 1000
+
+ t.string :summary, null: false, max_length: 255
+
+ t.jsonb :parameters, null: false, default: {}
+ t.text :script, null: false, max_length: 100_000
+ t.integer :created_by_id, null: false
+
+ t.boolean :enabled, null: false, default: true
+ t.timestamps
+ end
+ end
+end
diff --git a/lib/ai_bot/personas/persona.rb b/lib/ai_bot/personas/persona.rb
index c1955fed..cfb0d2bd 100644
--- a/lib/ai_bot/personas/persona.rb
+++ b/lib/ai_bot/personas/persona.rb
@@ -130,7 +130,11 @@ module DiscourseAi
end
def available_tools
- self.class.all_available_tools.filter { |tool| tools.include?(tool) }
+ self
+ .class
+ .all_available_tools
+ .filter { |tool| tools.include?(tool) }
+ .concat(tools.filter(&:custom?))
end
def craft_prompt(context, llm: nil)
diff --git a/lib/ai_bot/tool_runner.rb b/lib/ai_bot/tool_runner.rb
new file mode 100644
index 00000000..fe69d9a5
--- /dev/null
+++ b/lib/ai_bot/tool_runner.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+module DiscourseAi
+ module AiBot
+ class ToolRunner
+ attr_reader :tool, :parameters, :llm
+ attr_accessor :running_attached_function, :timeout
+
+ TooManyRequestsError = Class.new(StandardError)
+
+ DEFAULT_TIMEOUT = 2000
+ MAX_MEMORY = 10_000_000
+ MARSHAL_STACK_DEPTH = 20
+ MAX_HTTP_REQUESTS = 20
+
+ def initialize(parameters:, llm:, bot_user:, context: {}, tool:, timeout: nil)
+ @parameters = parameters
+ @llm = llm
+ @bot_user = bot_user
+ @context = context
+ @tool = tool
+ @timeout = timeout || DEFAULT_TIMEOUT
+ @running_attached_function = false
+
+ @http_requests_made = 0
+ end
+
+ def mini_racer_context
+ @mini_racer_context ||=
+ begin
+ ctx =
+ MiniRacer::Context.new(
+ max_memory: MAX_MEMORY,
+ marshal_stack_depth: MARSHAL_STACK_DEPTH,
+ )
+ attach_truncate(ctx)
+ attach_http(ctx)
+ ctx.eval(framework_script)
+ ctx
+ end
+ end
+
+ def framework_script
+ <<~JS
+ const http = {
+ get: function(url, options) { return _http_get(url, options) },
+ post: function(url, options) { return _http_post(url, options) },
+ };
+
+ const llm = {
+ truncate: _llm_truncate,
+ };
+ function details() { return ""; };
+ JS
+ end
+
+ def details
+ eval_with_timeout("details()")
+ end
+
+ def eval_with_timeout(script, timeout: nil)
+ timeout ||= @timeout
+ mutex = Mutex.new
+ done = false
+ elapsed = 0
+
+ t =
+ Thread.new do
+ begin
+ while !done
+ # this is not accurate. but reasonable enough for a timeout
+ sleep(0.001)
+ elapsed += 1 if !self.running_attached_function
+ if elapsed > timeout
+ mutex.synchronize { mini_racer_context.stop unless done }
+ break
+ end
+ end
+ rescue => e
+ STDERR.puts e
+ STDERR.puts "FAILED TO TERMINATE DUE TO TIMEOUT"
+ end
+ end
+
+ rval = mini_racer_context.eval(script)
+
+ mutex.synchronize { done = true }
+
+ # ensure we do not leak a thread in state
+ t.join
+ t = nil
+
+ rval
+ ensure
+ # exceptions need to be handled
+ t&.join
+ end
+
+ def invoke
+ mini_racer_context.eval(tool.script)
+ eval_with_timeout("invoke(#{JSON.generate(parameters)})")
+ rescue MiniRacer::ScriptTerminatedError
+ { error: "Script terminated due to timeout" }
+ end
+
+ private
+
+ def attach_truncate(mini_racer_context)
+ mini_racer_context.attach(
+ "_llm_truncate",
+ ->(text, length) { @llm.tokenizer.truncate(text, length) },
+ )
+ end
+
+ def attach_http(mini_racer_context)
+ mini_racer_context.attach(
+ "_http_get",
+ ->(url, options) do
+ begin
+ @http_requests_made += 1
+ if @http_requests_made > MAX_HTTP_REQUESTS
+ raise TooManyRequestsError.new("Tool made too many HTTP requests")
+ end
+
+ self.running_attached_function = true
+ headers = (options && options["headers"]) || {}
+
+ result = {}
+ DiscourseAi::AiBot::Tools::Tool.send_http_request(url, headers: headers) do |response|
+ result[:body] = response.body
+ result[:status] = response.code.to_i
+ end
+
+ result
+ ensure
+ self.running_attached_function = false
+ end
+ end,
+ )
+
+ mini_racer_context.attach(
+ "_http_post",
+ ->(url, options) do
+ begin
+ @http_requests_made += 1
+ if @http_requests_made > MAX_HTTP_REQUESTS
+ raise TooManyRequestsError.new("Tool made too many HTTP requests")
+ end
+
+ self.running_attached_function = true
+ headers = (options && options["headers"]) || {}
+ body = options && options["body"]
+
+ result = {}
+ DiscourseAi::AiBot::Tools::Tool.send_http_request(
+ url,
+ method: :post,
+ headers: headers,
+ body: body,
+ ) do |response|
+ result[:body] = response.body
+ result[:status] = response.code.to_i
+ end
+
+ result
+ ensure
+ self.running_attached_function = false
+ end
+ end,
+ )
+ end
+ end
+ end
+end
diff --git a/lib/ai_bot/tools/custom.rb b/lib/ai_bot/tools/custom.rb
new file mode 100644
index 00000000..db3ed379
--- /dev/null
+++ b/lib/ai_bot/tools/custom.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module DiscourseAi
+ module AiBot
+ module Tools
+ class Custom < Tool
+ def self.class_instance(tool_id)
+ klass = Class.new(self)
+ klass.tool_id = tool_id
+ klass
+ end
+
+ def self.custom?
+ true
+ end
+
+ def self.tool_id
+ @tool_id
+ end
+
+ def self.tool_id=(tool_id)
+ @tool_id = tool_id
+ end
+
+ def self.signature
+ AiTool.find(tool_id).signature
+ end
+
+ def self.name
+ AiTool.where(id: tool_id).pluck(:name).first
+ end
+
+ def invoke
+ runner.invoke
+ end
+
+ def runner
+ @runner ||= ai_tool.runner(parameters, llm: llm, bot_user: bot_user, context: context)
+ end
+
+ def ai_tool
+ @ai_tool ||= AiTool.find(self.class.tool_id)
+ end
+
+ def summary
+ ai_tool.summary
+ end
+
+ def details
+ runner.details
+ end
+
+ def help
+ # I do not think this is called, but lets make sure
+ raise "Not implemented"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ai_bot/tools/tool.rb b/lib/ai_bot/tools/tool.rb
index a695036d..32688a18 100644
--- a/lib/ai_bot/tools/tool.rb
+++ b/lib/ai_bot/tools/tool.rb
@@ -19,6 +19,10 @@ module DiscourseAi
raise NotImplemented
end
+ def custom?
+ false
+ end
+
def accepted_options
[]
end
@@ -124,7 +128,34 @@ module DiscourseAi
response_code == "200" ? repo_data["default_branch"] : "main"
end
- def send_http_request(url, headers: {}, authenticate_github: false, follow_redirects: false)
+ 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
@@ -152,7 +183,17 @@ module DiscourseAi
return if uri.blank?
- request = FinalDestination::HTTP::Get.new(uri)
+ 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?
diff --git a/plugin.rb b/plugin.rb
index 3e4cff4f..a03eadae 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -28,6 +28,8 @@ register_asset "stylesheets/modules/sentiment/mobile/dashboard.scss", :mobile
register_asset "stylesheets/modules/llms/common/ai-llms-editor.scss"
+register_asset "stylesheets/modules/ai-bot/common/ai-tools.scss"
+
module ::DiscourseAi
PLUGIN_NAME = "discourse-ai"
end
diff --git a/spec/fabricators/llm_model_fabricator.rb b/spec/fabricators/llm_model_fabricator.rb
index c419341e..27cdf658 100644
--- a/spec/fabricators/llm_model_fabricator.rb
+++ b/spec/fabricators/llm_model_fabricator.rb
@@ -4,6 +4,6 @@ Fabricator(:llm_model) do
display_name "A good model"
name "gpt-4-turbo"
provider "open_ai"
- tokenizer "DiscourseAi::Tokenizers::OpenAi"
+ tokenizer "DiscourseAi::Tokenizer::OpenAiTokenizer"
max_prompt_tokens 32_000
end
diff --git a/spec/lib/modules/ai_bot/playground_spec.rb b/spec/lib/modules/ai_bot/playground_spec.rb
index ab0daa9a..a528a13e 100644
--- a/spec/lib/modules/ai_bot/playground_spec.rb
+++ b/spec/lib/modules/ai_bot/playground_spec.rb
@@ -63,6 +63,88 @@ RSpec.describe DiscourseAi::AiBot::Playground do
end
end
+ describe "custom tool integration" do
+ let!(:custom_tool) do
+ AiTool.create!(
+ name: "search",
+ summary: "searching for things",
+ description: "A test custom tool",
+ parameters: [{ name: "query", type: "string", description: "Input for the custom tool" }],
+ script:
+ "function invoke(params) { return 'Custom tool result: ' + params.query; }; function details() { return 'did stuff'; }",
+ created_by: user,
+ )
+ end
+
+ let!(:ai_persona) { Fabricate(:ai_persona, tools: ["custom-#{custom_tool.id}"]) }
+
+ it "uses custom tool in conversation" do
+ persona_klass = AiPersona.all_personas.find { |p| p.name == ai_persona.name }
+ bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona_klass.new)
+ playground = DiscourseAi::AiBot::Playground.new(bot)
+
+ function_call = (<<~XML).strip
+
+
+ search
+ 666
+
+ Can you use the custom tool
+
+
+ ",
+ XML
+
+ responses = [function_call, "custom tool did stuff (maybe)"]
+
+ reply_post = nil
+
+ DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompt|
+ new_post = Fabricate(:post, raw: "Can you use the custom tool?")
+ reply_post = playground.reply_to(new_post)
+ end
+
+ expected = <<~TXT.strip
+
+ searching for things
+
did stuff
+
+
+
+ custom tool did stuff (maybe)
+ TXT
+ expect(reply_post.raw).to eq(expected)
+
+ custom_prompt = PostCustomPrompt.find_by(post_id: reply_post.id).custom_prompt
+ expected_prompt = [
+ [
+ "{\"arguments\":{\"query\":\"Can you use the custom tool\"}}",
+ "666",
+ "tool_call",
+ "search",
+ ],
+ ["\"Custom tool result: Can you use the custom tool\"", "666", "tool", "search"],
+ ["custom tool did stuff (maybe)", "claude-2"],
+ ]
+
+ expect(custom_prompt).to eq(expected_prompt)
+
+ custom_tool.update!(enabled: false)
+ # so we pick up new cache
+ persona_klass = AiPersona.all_personas.find { |p| p.name == ai_persona.name }
+ bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona_klass.new)
+ playground = DiscourseAi::AiBot::Playground.new(bot)
+
+ # lets ensure tool does not run...
+ DiscourseAi::Completions::Llm.with_prepared_responses(responses) do |_, _, _prompt|
+ new_post = Fabricate(:post, raw: "Can you use the custom tool?")
+ reply_post = playground.reply_to(new_post)
+ end
+
+ expect(reply_post.raw.strip).to eq(function_call)
+ end
+ end
+
describe "image support" do
before do
Jobs.run_immediately!
@@ -459,7 +541,6 @@ RSpec.describe DiscourseAi::AiBot::Playground do
it "picks the correct llm for persona in PMs" do
gpt_35_turbo = Fabricate(:llm_model, name: "gpt-3.5-turbo")
- gpt_35_turbo_16k = Fabricate(:llm_model, name: "gpt-3.5-turbo-16k")
# If you start a PM with GPT 3.5 bot, replies should come from it, not from Claude
SiteSetting.ai_bot_enabled = true
diff --git a/spec/models/ai_tool_spec.rb b/spec/models/ai_tool_spec.rb
new file mode 100644
index 00000000..075ad5be
--- /dev/null
+++ b/spec/models/ai_tool_spec.rb
@@ -0,0 +1,196 @@
+# frozen_string_literal: true
+
+RSpec.describe AiTool do
+ fab!(:llm_model) { Fabricate(:llm_model, name: "claude-2") }
+ let(:llm) { DiscourseAi::Completions::Llm.proxy_from_obj(llm_model) }
+
+ def create_tool(parameters: nil, script: nil)
+ AiTool.create!(
+ name: "test",
+ description: "test",
+ parameters: parameters || [{ name: "query", type: "string", desciption: "perform a search" }],
+ script: script || "function invoke(params) { return params; }",
+ created_by_id: 1,
+ summary: "Test tool summary",
+ )
+ end
+
+ it "it can run a basic tool" do
+ tool = create_tool
+
+ expect(tool.signature).to eq(
+ {
+ name: "test",
+ description: "test",
+ parameters: [{ name: "query", type: "string", desciption: "perform a search" }],
+ },
+ )
+
+ runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil, context: {})
+
+ expect(runner.invoke).to eq("query" => "test")
+ end
+
+ it "can perform POST HTTP requests" do
+ script = <<~JS
+ function invoke(params) {
+ result = http.post("https://example.com/api",
+ {
+ headers: { TestHeader: "TestValue" },
+ body: JSON.stringify({ data: params.data })
+ }
+ );
+
+ return result.body;
+ }
+ JS
+
+ tool = create_tool(script: script)
+ runner = tool.runner({ "data" => "test data" }, llm: nil, bot_user: nil, context: {})
+
+ stub_request(:post, "https://example.com/api").with(
+ body: "{\"data\":\"test data\"}",
+ headers: {
+ "Accept" => "*/*",
+ "Testheader" => "TestValue",
+ "User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)",
+ },
+ ).to_return(status: 200, body: "Success", headers: {})
+
+ result = runner.invoke
+
+ expect(result).to eq("Success")
+ end
+
+ it "can perform GET HTTP requests, with 1 param" do
+ script = <<~JS
+ function invoke(params) {
+ result = http.get("https://example.com/" + params.query);
+ return result.body;
+ }
+ JS
+
+ tool = create_tool(script: script)
+ runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil, context: {})
+
+ stub_request(:get, "https://example.com/test").with(
+ headers: {
+ "Accept" => "*/*",
+ "User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)",
+ },
+ ).to_return(status: 200, body: "Hello World", headers: {})
+
+ result = runner.invoke
+
+ expect(result).to eq("Hello World")
+ end
+
+ it "is limited to MAX http requests" do
+ script = <<~JS
+ function invoke(params) {
+ let i = 0;
+ while (i < 21) {
+ http.get("https://example.com/");
+ i += 1;
+ }
+ return "will not happen";
+ }
+ JS
+
+ tool = create_tool(script: script)
+ runner = tool.runner({}, llm: nil, bot_user: nil, context: {})
+
+ stub_request(:get, "https://example.com/").to_return(
+ status: 200,
+ body: "Hello World",
+ headers: {
+ },
+ )
+
+ expect { runner.invoke }.to raise_error(DiscourseAi::AiBot::ToolRunner::TooManyRequestsError)
+ end
+
+ it "can perform GET HTTP requests" do
+ script = <<~JS
+ function invoke(params) {
+ result = http.get("https://example.com/" + params.query,
+ { headers: { TestHeader: "TestValue" } }
+ );
+
+ return result.body;
+ }
+ JS
+
+ tool = create_tool(script: script)
+ runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil, context: {})
+
+ stub_request(:get, "https://example.com/test").with(
+ headers: {
+ "Accept" => "*/*",
+ "Testheader" => "TestValue",
+ "User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)",
+ },
+ ).to_return(status: 200, body: "Hello World", headers: {})
+
+ result = runner.invoke
+
+ expect(result).to eq("Hello World")
+ end
+
+ it "will not timeout on slow HTTP reqs" do
+ script = <<~JS
+ function invoke(params) {
+ result = http.get("https://example.com/" + params.query,
+ { headers: { TestHeader: "TestValue" } }
+ );
+
+ return result.body;
+ }
+ JS
+
+ tool = create_tool(script: script)
+ runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil, context: {})
+
+ stub_request(:get, "https://example.com/test").to_return do
+ sleep 0.01
+ { status: 200, body: "Hello World", headers: {} }
+ end
+
+ runner.timeout = 5
+
+ result = runner.invoke
+
+ expect(result).to eq("Hello World")
+ end
+
+ it "has access to llm truncation tools" do
+ script = <<~JS
+ function invoke(params) {
+ return llm.truncate("Hello World", 1);
+ }
+ JS
+
+ tool = create_tool(script: script)
+
+ runner = tool.runner({}, llm: llm, bot_user: nil, context: {})
+ result = runner.invoke
+
+ expect(result).to eq("Hello")
+ end
+
+ it "can timeout slow JS" do
+ script = <<~JS
+ function invoke(params) {
+ while (true) {}
+ }
+ JS
+
+ tool = create_tool(script: script)
+ runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil, context: {})
+
+ runner.timeout = 5
+
+ result = runner.invoke
+ expect(result[:error]).to eq("Script terminated due to timeout")
+ end
+end
diff --git a/spec/requests/admin/ai_tools_controller_spec.rb b/spec/requests/admin/ai_tools_controller_spec.rb
new file mode 100644
index 00000000..42a61e7f
--- /dev/null
+++ b/spec/requests/admin/ai_tools_controller_spec.rb
@@ -0,0 +1,150 @@
+# frozen_string_literal: true
+
+RSpec.describe DiscourseAi::Admin::AiToolsController do
+ fab!(:admin)
+ fab!(:ai_tool) do
+ AiTool.create!(
+ name: "Test Tool",
+ description: "A test tool",
+ script: "function invoke(params) { return params; }",
+ parameters: [{ name: "query", type: "string", description: "perform a search" }],
+ summary: "Test tool summary",
+ created_by_id: -1,
+ )
+ end
+
+ before do
+ sign_in(admin)
+ SiteSetting.ai_embeddings_enabled = true
+ end
+
+ describe "GET #index" do
+ it "returns a success response" do
+ get "/admin/plugins/discourse-ai/ai-tools.json"
+ expect(response).to be_successful
+ expect(response.parsed_body["ai_tools"].length).to eq(AiTool.count)
+ expect(response.parsed_body["meta"]["presets"].length).to be > 0
+ end
+ end
+
+ describe "GET #show" do
+ it "returns a success response" do
+ get "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json"
+ expect(response).to be_successful
+ expect(response.parsed_body["ai_tool"]["name"]).to eq(ai_tool.name)
+ end
+ end
+
+ describe "POST #create" do
+ let(:valid_attributes) do
+ {
+ name: "Test Tool",
+ description: "A test tool",
+ parameters: [{ name: "query", type: "string", description: "perform a search" }],
+ script: "function invoke(params) { return params; }",
+ summary: "Test tool summary",
+ }
+ end
+
+ it "creates a new AiTool" do
+ expect {
+ post "/admin/plugins/discourse-ai/ai-tools.json",
+ params: { ai_tool: valid_attributes }.to_json,
+ headers: {
+ "CONTENT_TYPE" => "application/json",
+ }
+ }.to change(AiTool, :count).by(1)
+
+ expect(response).to have_http_status(:created)
+ expect(response.parsed_body["ai_tool"]["name"]).to eq("Test Tool")
+ end
+ end
+
+ describe "PUT #update" do
+ it "updates the requested ai_tool" do
+ put "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json",
+ params: {
+ ai_tool: {
+ name: "Updated Tool",
+ },
+ }
+
+ expect(response).to be_successful
+ expect(ai_tool.reload.name).to eq("Updated Tool")
+ end
+ end
+
+ describe "DELETE #destroy" do
+ it "destroys the requested ai_tool" do
+ expect { delete "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json" }.to change(
+ AiTool,
+ :count,
+ ).by(-1)
+
+ expect(response).to have_http_status(:no_content)
+ end
+ end
+
+ describe "#test" do
+ it "runs an existing tool and returns the result" do
+ post "/admin/plugins/discourse-ai/ai-tools/test.json",
+ params: {
+ id: ai_tool.id,
+ parameters: {
+ input: "Hello, World!",
+ },
+ }
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["output"]).to eq("input" => "Hello, World!")
+ end
+
+ it "runs a new unsaved tool and returns the result" do
+ post "/admin/plugins/discourse-ai/ai-tools/test.json",
+ params: {
+ ai_tool: {
+ name: "New Tool",
+ description: "A new test tool",
+ script: "function invoke(params) { return 'New test result: ' + params.input; }",
+ parameters: [
+ { name: "input", type: "string", description: "Input for the new test tool" },
+ ],
+ },
+ parameters: {
+ input: "Test input",
+ },
+ }
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["output"]).to eq("New test result: Test input")
+ end
+
+ it "returns an error for invalid tool_id" do
+ post "/admin/plugins/discourse-ai/ai-tools/test.json",
+ params: {
+ id: -1,
+ parameters: {
+ input: "Hello, World!",
+ },
+ }
+
+ expect(response.status).to eq(400)
+ expect(response.parsed_body["errors"]).to include("Couldn't find AiTool with 'id'=-1")
+ end
+
+ it "handles exceptions during tool execution" do
+ ai_tool.update!(script: "function invoke(params) { throw new Error('Test error'); }")
+
+ post "/admin/plugins/discourse-ai/ai-tools/test.json",
+ params: {
+ id: ai_tool.id,
+ parameters: {
+ input: "Hello, World!",
+ },
+ }
+
+ expect(response.status).to eq(400)
+ expect(response.parsed_body["errors"].to_s).to include("Error executing the tool")
+ end
+ end
+end
diff --git a/spec/system/ai_bot/tool_spec.rb b/spec/system/ai_bot/tool_spec.rb
new file mode 100644
index 00000000..ec397831
--- /dev/null
+++ b/spec/system/ai_bot/tool_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe "AI Tool Management", type: :system do
+ fab!(:admin)
+
+ before do
+ SiteSetting.ai_embeddings_enabled = true
+ sign_in(admin)
+ end
+
+ it "allows admin to create a new AI tool from preset" do
+ visit "/admin/plugins/discourse-ai/ai-tools"
+
+ expect(page).to have_content("Tools")
+
+ find(".ai-tool-list-editor__new-button").click
+
+ select_kit = PageObjects::Components::SelectKit.new(".ai-tool-editor__presets")
+ select_kit.expand
+ select_kit.select_row_by_value("exchange_rate")
+
+ find(".ai-tool-editor__next").click
+ find(".ai-tool-editor__test-button").click
+
+ expect(page).not_to have_button(".ai-tool-editor__delete")
+
+ modal = PageObjects::Modals::AiToolTest.new
+ modal.base_currency = "USD"
+ modal.target_currency = "EUR"
+ modal.amount = "100"
+
+ stub_request(:get, %r{https://open\.er-api\.com/v6/latest/USD}).to_return(
+ status: 200,
+ body: '{"rates": {"EUR": 0.85}}',
+ headers: {
+ "Content-Type" => "application/json",
+ },
+ )
+ modal.run_test
+
+ expect(modal).to have_content("exchange_rate")
+ expect(modal).to have_content("0.85")
+
+ modal.close
+
+ find(".ai-tool-editor__save").click
+
+ expect(page).to have_content("Tool saved")
+
+ visit "/admin/plugins/discourse-ai/ai-personas/new"
+
+ tool_id = AiTool.order("id desc").limit(1).pluck(:id).first
+ tool_selector = PageObjects::Components::SelectKit.new(".ai-persona-editor__tools")
+ tool_selector.expand
+
+ tool_selector.select_row_by_value("custom-#{tool_id}")
+ expect(tool_selector).to have_selected_value("custom-#{tool_id}")
+ end
+end
diff --git a/spec/system/page_objects/modals/ai_tool_test_modal.rb b/spec/system/page_objects/modals/ai_tool_test_modal.rb
new file mode 100644
index 00000000..514581a1
--- /dev/null
+++ b/spec/system/page_objects/modals/ai_tool_test_modal.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Modals
+ class AiToolTest < PageObjects::Modals::Base
+ BODY_SELECTOR = ".ai-tool-test-modal__body"
+ MODAL_SELECTOR = ".ai-tool-test-modal"
+
+ def base_currency=(value)
+ body.fill_in("base_currency", with: value)
+ end
+
+ def target_currency=(value)
+ body.fill_in("target_currency", with: value)
+ end
+
+ def amount=(value)
+ body.fill_in("amount", with: value)
+ end
+
+ def run_test
+ click_primary_button
+ end
+ end
+ end
+end