feat: Add UI for custom tools (#12585)
### Tools list <img width="2316" height="666" alt="CleanShot 2025-10-03 at 20 42 41@2x" src="https://github.com/user-attachments/assets/ccbffd16-804d-4eb8-9c64-2d1cfd407e4e" /> ### Tools form <img width="2294" height="2202" alt="CleanShot 2025-10-03 at 20 43 05@2x" src="https://github.com/user-attachments/assets/9f49aa09-75a1-4585-a09d-837ca64139b8" /> ## Response <img width="800" height="2144" alt="CleanShot 2025-10-03 at 20 45 56@2x" src="https://github.com/user-attachments/assets/b0c3c899-6050-4c51-baed-c8fbec5aae61" /> --------- Co-authored-by: Pranav <pranavrajs@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
@@ -29,6 +29,8 @@ class Captain::CustomTool < ApplicationRecord
|
||||
|
||||
self.table_name = 'captain_custom_tools'
|
||||
|
||||
NAME_PREFIX = 'custom'.freeze
|
||||
NAME_SEPARATOR = '_'.freeze
|
||||
PARAM_SCHEMA_VALIDATION = {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
@@ -73,16 +75,23 @@ class Captain::CustomTool < ApplicationRecord
|
||||
|
||||
def generate_slug
|
||||
return if slug.present?
|
||||
return if title.blank?
|
||||
|
||||
base_slug = title.present? ? "custom_#{title.parameterize}" : "custom_#{SecureRandom.uuid}"
|
||||
paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
||||
|
||||
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
|
||||
self.slug = find_unique_slug(base_slug)
|
||||
end
|
||||
|
||||
def find_unique_slug(base_slug, counter = 0)
|
||||
slug_candidate = counter.zero? ? base_slug : "#{base_slug}-#{counter}"
|
||||
return find_unique_slug(base_slug, counter + 1) if slug_exists?(slug_candidate)
|
||||
def find_unique_slug(base_slug)
|
||||
return base_slug unless slug_exists?(base_slug)
|
||||
|
||||
slug_candidate
|
||||
5.times do
|
||||
slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
||||
return slug_candidate unless slug_exists?(slug_candidate)
|
||||
end
|
||||
|
||||
raise ActiveRecord::RecordNotUnique, I18n.t('captain.custom_tool.slug_generation_failed')
|
||||
end
|
||||
|
||||
def slug_exists?(candidate)
|
||||
|
||||
@@ -3,7 +3,10 @@ module Concerns::Toolable
|
||||
|
||||
def tool(assistant)
|
||||
custom_tool_record = self
|
||||
# Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize)
|
||||
class_name = custom_tool_record.slug.underscore.camelize
|
||||
|
||||
# Always create a fresh class to reflect current metadata
|
||||
tool_class = Class.new(Captain::Tools::HttpTool) do
|
||||
description custom_tool_record.description
|
||||
|
||||
@@ -15,6 +18,16 @@ module Concerns::Toolable
|
||||
end
|
||||
end
|
||||
|
||||
# Register the dynamically created class as a constant in the Captain::Tools namespace.
|
||||
# This is required because RubyLLM's Tool base class derives the tool name from the class name
|
||||
# (via Class#name). Anonymous classes created with Class.new have no name and return empty strings,
|
||||
# which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API.
|
||||
# By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup")
|
||||
# which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API.
|
||||
# We refresh the constant on each call to ensure tool metadata changes are reflected.
|
||||
Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false)
|
||||
Captain::Tools.const_set(class_name, tool_class)
|
||||
|
||||
tool_class.new(assistant, self)
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user