diff --git a/Gemfile b/Gemfile index b4752c745..53ba9f783 100644 --- a/Gemfile +++ b/Gemfile @@ -177,6 +177,7 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' +gem 'ai-agents', '>= 0.2.1' gem 'shopify_api' diff --git a/Gemfile.lock b/Gemfile.lock index 8315a5374..d55cfa0a9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,6 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) + ai-agents (0.2.1) + ruby_llm (~> 1.3) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) @@ -717,6 +719,15 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) + ruby_llm (1.3.1) + base64 + event_stream_parser (~> 1) + faraday (>= 1.10.0) + faraday-multipart (>= 1) + faraday-net_http (>= 1) + faraday-retry (>= 1) + marcel (~> 1.0) + zeitwerk (~> 2) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -895,6 +906,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) + ai-agents (>= 0.2.1) annotate attr_extras audited (~> 5.4, >= 5.4.1) diff --git a/config/agents/tools.yml b/config/agents/tools.yml new file mode 100644 index 000000000..b994e93d3 --- /dev/null +++ b/config/agents/tools.yml @@ -0,0 +1,26 @@ +###### Captain Agent Tools Configuration ####### +# id: Tool identifier used to resolve the class (Captain::Tools::{PascalCase(id)}Tool) +# title: Human-readable tool name for frontend display +# description: Brief description of what the tool does +# icon: Icon name for frontend display (frontend icon system) +####################################################### + +- id: add_contact_note + title: 'Add Contact Note' + description: 'Add a note to a contact profile' + icon: 'note-add' + +- id: add_private_note + title: 'Add Private Note' + description: 'Add a private note to a conversation (internal only)' + icon: 'eye-off' + +- id: update_priority + title: 'Update Priority' + description: 'Update conversation priority level' + icon: 'exclamation-triangle' + +- id: add_label_to_conversation + title: 'Add Label to Conversation' + description: 'Add a label to a conversation' + icon: 'tag' diff --git a/config/routes.rb b/config/routes.rb index f262febdf..681c21e44 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,9 @@ Rails.application.routes.draw do member do post :playground end + collection do + get :tools + end resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id resources :scenarios end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb index 1bd928271..aaf81677a 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -32,6 +32,10 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base render json: response end + def tools + @tools = Captain::Assistant.available_agent_tools + end + private def set_assistant diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 4ce4c4afd..cdf2b53f3 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -18,6 +18,7 @@ # class Captain::Assistant < ApplicationRecord include Avatarable + include Concerns::CaptainToolsHelpers self.table_name = 'captain_assistants' diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb index ff8e63c64..ecfba396f 100644 --- a/enterprise/app/models/captain/scenario.rb +++ b/enterprise/app/models/captain/scenario.rb @@ -21,6 +21,8 @@ # index_captain_scenarios_on_enabled (enabled) # class Captain::Scenario < ApplicationRecord + include Concerns::CaptainToolsHelpers + self.table_name = 'captain_scenarios' belongs_to :assistant, class_name: 'Captain::Assistant' @@ -31,14 +33,60 @@ class Captain::Scenario < ApplicationRecord validates :instruction, presence: true validates :assistant_id, presence: true validates :account_id, presence: true + validate :validate_instruction_tools scope :enabled, -> { where(enabled: true) } - before_save :populate_tools + before_save :resolve_tool_references private - def populate_tools - # TODO: Implement tools population logic + # Validates that all tool references in the instruction are valid. + # Parses the instruction for tool references and checks if they exist + # in the available tools configuration. + # + # @return [void] + # @api private + # @example Valid instruction + # scenario.instruction = "Use [Add Contact Note](tool://add_contact_note) to document" + # scenario.valid? # => true + # + # @example Invalid instruction + # scenario.instruction = "Use [Invalid Tool](tool://invalid_tool) to process" + # scenario.valid? # => false + # scenario.errors[:instruction] # => ["contains invalid tools: invalid_tool"] + def validate_instruction_tools + return if instruction.blank? + + tool_ids = extract_tool_ids_from_text(instruction) + return if tool_ids.empty? + + available_tool_ids = self.class.available_tool_ids + invalid_tools = tool_ids - available_tool_ids + + return unless invalid_tools.any? + + errors.add(:instruction, "contains invalid tools: #{invalid_tools.join(', ')}") + end + + # Resolves tool references from the instruction text into the tools field. + # Parses the instruction for tool references and materializes them as + # tool IDs stored in the tools JSONB field. + # + # @return [void] + # @api private + # @example + # scenario.instruction = "First [@Add Private Note](tool://add_private_note) then [@Update Priority](tool://update_priority)" + # scenario.save! + # scenario.tools # => ["add_private_note", "update_priority"] + # + # scenario.instruction = "No tools mentioned here" + # scenario.save! + # scenario.tools # => nil + def resolve_tool_references + return if instruction.blank? + + tool_ids = extract_tool_ids_from_text(instruction) + self.tools = tool_ids.presence end end diff --git a/enterprise/app/models/concerns/captain_tools_helpers.rb b/enterprise/app/models/concerns/captain_tools_helpers.rb new file mode 100644 index 000000000..5a660310c --- /dev/null +++ b/enterprise/app/models/concerns/captain_tools_helpers.rb @@ -0,0 +1,76 @@ +# Provides helper methods for working with Captain agent tools including +# tool resolution, text parsing, and metadata retrieval. +module Concerns::CaptainToolsHelpers + extend ActiveSupport::Concern + + # Regular expression pattern for matching tool references in text. + # Matches patterns like [Tool name](tool://tool_id) following markdown link syntax. + TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)} + + class_methods do + # Returns all available agent tools with their metadata. + # Only includes tools that have corresponding class files and can be resolved. + # + # @return [Array] Array of tool hashes with :id, :title, :description, :icon + def available_agent_tools + @available_agent_tools ||= load_agent_tools + end + + # Resolves a tool class from a tool ID. + # Converts snake_case tool IDs to PascalCase class names and constantizes them. + # + # @param tool_id [String] The snake_case tool identifier + # @return [Class, nil] The tool class if found, nil if not resolvable + def resolve_tool_class(tool_id) + class_name = "Captain::Tools::#{tool_id.classify}Tool" + class_name.safe_constantize + end + + # Returns an array of all available tool IDs. + # Convenience method that extracts just the IDs from available_agent_tools. + # + # @return [Array] Array of available tool IDs + def available_tool_ids + @available_tool_ids ||= available_agent_tools.map { |tool| tool[:id] } + end + + private + + # Loads agent tools from the YAML configuration file. + # Filters out tools that cannot be resolved to actual classes. + # + # @return [Array] Array of resolvable tools with metadata + # @api private + def load_agent_tools + tools_config = YAML.load_file(Rails.root.join('config/agents/tools.yml')) + + tools_config.filter_map do |tool_config| + tool_class = resolve_tool_class(tool_config['id']) + + if tool_class + { + id: tool_config['id'], + title: tool_config['title'], + description: tool_config['description'], + icon: tool_config['icon'] + } + else + Rails.logger.warn "Tool class not found for ID: #{tool_config['id']}" + nil + end + end + end + end + + # Extracts tool IDs from text containing tool references. + # Parses text for (tool://tool_id) patterns and returns unique tool IDs. + # + # @param text [String] Text to parse for tool references + # @return [Array] Array of unique tool IDs found in the text + def extract_tool_ids_from_text(text) + return [] if text.blank? + + tool_matches = text.scan(TOOL_REFERENCE_REGEX) + tool_matches.flatten.uniq + end +end diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb index 2a03fc0ea..7fbb47aa9 100644 --- a/enterprise/app/policies/captain/assistant_policy.rb +++ b/enterprise/app/policies/captain/assistant_policy.rb @@ -7,6 +7,10 @@ class Captain::AssistantPolicy < ApplicationPolicy true end + def tools? + @account_user.administrator? + end + def create? @account_user.administrator? end diff --git a/enterprise/app/views/api/v1/accounts/captain/assistants/tools.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/assistants/tools.json.jbuilder new file mode 100644 index 000000000..3a993caeb --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/assistants/tools.json.jbuilder @@ -0,0 +1,6 @@ +json.array! @tools do |tool| + json.id tool[:id] + json.title tool[:title] + json.description tool[:description] + json.icon tool[:icon] +end diff --git a/enterprise/lib/captain/tools/add_contact_note_tool.rb b/enterprise/lib/captain/tools/add_contact_note_tool.rb new file mode 100644 index 000000000..e1475abf0 --- /dev/null +++ b/enterprise/lib/captain/tools/add_contact_note_tool.rb @@ -0,0 +1,26 @@ +class Captain::Tools::AddContactNoteTool < Captain::Tools::BasePublicTool + description 'Add a note to a contact profile' + param :note, type: 'string', desc: 'The note content to add to the contact' + + def perform(tool_context, note:) + contact = find_contact(tool_context.state) + return 'Contact not found' unless contact + + return 'Note content is required' if note.blank? + + log_tool_usage('add_contact_note', { contact_id: contact.id, note_length: note.length }) + + create_contact_note(contact, note) + "Note added successfully to contact #{contact.name} (ID: #{contact.id})" + end + + private + + def create_contact_note(contact, note) + contact.notes.create!(content: note) + end + + def permissions + %w[contact_manage] + end +end diff --git a/enterprise/lib/captain/tools/add_label_to_conversation_tool.rb b/enterprise/lib/captain/tools/add_label_to_conversation_tool.rb new file mode 100644 index 000000000..429f33b7b --- /dev/null +++ b/enterprise/lib/captain/tools/add_label_to_conversation_tool.rb @@ -0,0 +1,34 @@ +class Captain::Tools::AddLabelToConversationTool < Captain::Tools::BasePublicTool + description 'Add a label to a conversation' + param :label_name, type: 'string', desc: 'The name of the label to add' + + def perform(tool_context, label_name:) + conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless conversation + + label_name = label_name&.strip&.downcase + return 'Label name is required' if label_name.blank? + + label = find_label(label_name) + return 'Label not found' unless label + + add_label_to_conversation(conversation, label_name) + + log_tool_usage('added_label', conversation_id: conversation.id, label: label_name) + + "Label '#{label_name}' added to conversation ##{conversation.display_id}" + end + + private + + def find_label(label_name) + account_scoped(Label).find_by(title: label_name) + end + + def add_label_to_conversation(conversation, label_name) + conversation.add_labels(label_name) + rescue StandardError => e + Rails.logger.error "Failed to add label to conversation: #{e.message}" + raise + end +end diff --git a/enterprise/lib/captain/tools/add_private_note_tool.rb b/enterprise/lib/captain/tools/add_private_note_tool.rb new file mode 100644 index 000000000..36e1ef977 --- /dev/null +++ b/enterprise/lib/captain/tools/add_private_note_tool.rb @@ -0,0 +1,33 @@ +class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool + description 'Add a private note to a conversation' + param :note, type: 'string', desc: 'The private note content' + + def perform(tool_context, note:) + conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless conversation + + return 'Note content is required' if note.blank? + + log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length }) + create_private_note(conversation, note) + + 'Private note added successfully' + end + + private + + def create_private_note(conversation, note) + conversation.messages.create!( + account: @assistant.account, + inbox: conversation.inbox, + sender: @assistant, + message_type: :outgoing, + content: note, + private: true + ) + end + + def permissions + %w[conversation_manage conversation_unassigned_manage conversation_participating_manage] + end +end diff --git a/enterprise/lib/captain/tools/base_public_tool.rb b/enterprise/lib/captain/tools/base_public_tool.rb new file mode 100644 index 000000000..e1f779b36 --- /dev/null +++ b/enterprise/lib/captain/tools/base_public_tool.rb @@ -0,0 +1,45 @@ +require 'agents' + +class Captain::Tools::BasePublicTool < Agents::Tool + def initialize(assistant) + @assistant = assistant + super() + end + + def active? + # Public tools are always active + true + end + + def permissions + # Override in subclasses to specify required permissions + # Returns empty array for public tools (no permissions required) + [] + end + + private + + def account_scoped(model_class) + model_class.where(account_id: @assistant.account_id) + end + + def find_conversation(state) + conversation_id = state&.dig(:conversation, :id) + return nil unless conversation_id + + account_scoped(::Conversation).find_by(id: conversation_id) + end + + def find_contact(state) + contact_id = state&.dig(:contact, :id) + return nil unless contact_id + + account_scoped(::Contact).find_by(id: contact_id) + end + + def log_tool_usage(action, details = {}) + Rails.logger.info do + "#{self.class.name}: #{action} for assistant #{@assistant&.id} - #{details.inspect}" + end + end +end diff --git a/enterprise/lib/captain/tools/update_priority_tool.rb b/enterprise/lib/captain/tools/update_priority_tool.rb new file mode 100644 index 000000000..8fc75f601 --- /dev/null +++ b/enterprise/lib/captain/tools/update_priority_tool.rb @@ -0,0 +1,48 @@ +class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool + description 'Update the priority of a conversation' + param :priority, type: 'string', desc: 'The priority level: low, medium, high, urgent, or nil to remove priority' + + def perform(tool_context, priority:) + @conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless @conversation + + @normalized_priority = normalize_priority(priority) + return "Invalid priority. Valid options: #{valid_priority_options}" unless valid_priority?(@normalized_priority) + + log_tool_usage('update_priority', { conversation_id: @conversation.id, priority: priority }) + + execute_priority_update + end + + private + + def execute_priority_update + update_conversation_priority(@conversation, @normalized_priority) + priority_text = @normalized_priority || 'none' + "Priority updated to '#{priority_text}' for conversation ##{@conversation.display_id}" + end + + def normalize_priority(priority) + priority == 'nil' || priority.blank? ? nil : priority + end + + def valid_priority?(priority) + valid_priorities.include?(priority) + end + + def valid_priorities + @valid_priorities ||= [nil] + Conversation.priorities.keys + end + + def valid_priority_options + (valid_priorities.compact + ['nil']).join(', ') + end + + def update_conversation_priority(conversation, priority) + conversation.update!(priority: priority) + end + + def permissions + %w[conversation_manage conversation_unassigned_manage conversation_participating_manage] + end +end diff --git a/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb new file mode 100644 index 000000000..c087242dc --- /dev/null +++ b/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::AddContactNoteTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:tool_context) { Struct.new(:state).new({ contact: { id: contact.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Add a note to a contact profile') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:note) + expect(tool.parameters[:note].name).to eq(:note) + expect(tool.parameters[:note].type).to eq('string') + expect(tool.parameters[:note].description).to eq('The note content to add to the contact') + end + end + + describe '#perform' do + context 'when contact exists' do + context 'with valid note content' do + it 'creates a contact note and returns success message' do + note_content = 'This is a contact note' + + expect do + result = tool.perform(tool_context, note: note_content) + expect(result).to eq("Note added successfully to contact #{contact.name} (ID: #{contact.id})") + end.to change(Note, :count).by(1) + + created_note = Note.last + expect(created_note.content).to eq(note_content) + expect(created_note.account).to eq(account) + expect(created_note.contact).to eq(contact) + expect(created_note.user).to eq(assistant.account.users.first) + end + + it 'logs tool usage' do + expect(tool).to receive(:log_tool_usage).with( + 'add_contact_note', + { contact_id: contact.id, note_length: 19 } + ) + + tool.perform(tool_context, note: 'This is a test note') + end + end + + context 'with blank note content' do + it 'returns error message' do + result = tool.perform(tool_context, note: '') + expect(result).to eq('Note content is required') + end + + it 'does not create a note' do + expect do + tool.perform(tool_context, note: '') + end.not_to change(Note, :count) + end + end + + context 'with nil note content' do + it 'returns error message' do + result = tool.perform(tool_context, note: nil) + expect(result).to eq('Note content is required') + end + end + end + + context 'when contact does not exist' do + let(:tool_context) { Struct.new(:state).new({ contact: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Contact not found') + end + + it 'does not create a note' do + expect do + tool.perform(tool_context, note: 'Some note') + end.not_to change(Note, :count) + end + end + + context 'when contact state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Contact not found') + end + end + + context 'when contact id is nil' do + let(:tool_context) { Struct.new(:state).new({ contact: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Contact not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb new file mode 100644 index 000000000..38e4dc7c6 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb @@ -0,0 +1,125 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::AddLabelToConversationTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:label) { create(:label, account: account, title: 'urgent') } + let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Add a label to a conversation') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:label_name) + expect(tool.parameters[:label_name].name).to eq(:label_name) + expect(tool.parameters[:label_name].type).to eq('string') + expect(tool.parameters[:label_name].description).to eq('The name of the label to add') + end + end + + describe '#perform' do + context 'when conversation exists' do + context 'with valid label that exists' do + before { label } + + it 'adds label to conversation and returns success message' do + result = tool.perform(tool_context, label_name: 'urgent') + expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}") + + expect(conversation.reload.label_list).to include('urgent') + end + + it 'logs tool usage' do + expect(tool).to receive(:log_tool_usage).with( + 'added_label', + { conversation_id: conversation.id, label: 'urgent' } + ) + + tool.perform(tool_context, label_name: 'urgent') + end + + it 'handles case insensitive label names' do + result = tool.perform(tool_context, label_name: 'URGENT') + expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}") + end + + it 'strips whitespace from label names' do + result = tool.perform(tool_context, label_name: ' urgent ') + expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}") + end + end + + context 'with label that does not exist' do + it 'returns error message' do + result = tool.perform(tool_context, label_name: 'nonexistent') + expect(result).to eq('Label not found') + end + + it 'does not add any labels to conversation' do + expect do + tool.perform(tool_context, label_name: 'nonexistent') + end.not_to(change { conversation.reload.labels.count }) + end + end + + context 'with blank label name' do + it 'returns error message for empty string' do + result = tool.perform(tool_context, label_name: '') + expect(result).to eq('Label name is required') + end + + it 'returns error message for nil' do + result = tool.perform(tool_context, label_name: nil) + expect(result).to eq('Label name is required') + end + + it 'returns error message for whitespace only' do + result = tool.perform(tool_context, label_name: ' ') + expect(result).to eq('Label name is required') + end + end + end + + context 'when conversation does not exist' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, label_name: 'urgent') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, label_name: 'urgent') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation id is nil' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, label_name: 'urgent') + expect(result).to eq('Conversation not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb new file mode 100644 index 000000000..cfce1a7d1 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb @@ -0,0 +1,124 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::AddPrivateNoteTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Add a private note to a conversation') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:note) + expect(tool.parameters[:note].name).to eq(:note) + expect(tool.parameters[:note].type).to eq('string') + expect(tool.parameters[:note].description).to eq('The private note content') + end + end + + describe '#perform' do + context 'when conversation exists' do + context 'with valid note content' do + it 'creates a private note and returns success message' do + note_content = 'This is a private note' + + expect do + result = tool.perform(tool_context, note: note_content) + expect(result).to eq('Private note added successfully') + end.to change(Message, :count).by(1) + end + + it 'creates a private note with correct attributes' do + note_content = 'This is a private note' + + tool.perform(tool_context, note: note_content) + + created_message = Message.last + expect(created_message.content).to eq(note_content) + expect(created_message.message_type).to eq('outgoing') + expect(created_message.private).to be true + expect(created_message.account).to eq(account) + expect(created_message.inbox).to eq(inbox) + expect(created_message.conversation).to eq(conversation) + end + + it 'logs tool usage' do + expect(tool).to receive(:log_tool_usage).with( + 'add_private_note', + { conversation_id: conversation.id, note_length: 19 } + ) + + tool.perform(tool_context, note: 'This is a test note') + end + end + + context 'with blank note content' do + it 'returns error message' do + result = tool.perform(tool_context, note: '') + expect(result).to eq('Note content is required') + end + + it 'does not create a message' do + expect do + tool.perform(tool_context, note: '') + end.not_to change(Message, :count) + end + end + + context 'with nil note content' do + it 'returns error message' do + result = tool.perform(tool_context, note: nil) + expect(result).to eq('Note content is required') + end + end + end + + context 'when conversation does not exist' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Conversation not found') + end + + it 'does not create a message' do + expect do + tool.perform(tool_context, note: 'Some note') + end.not_to change(Message, :count) + end + end + + context 'when conversation state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation id is nil' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Conversation not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb b/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb new file mode 100644 index 000000000..9aa858593 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb @@ -0,0 +1,117 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::UpdatePriorityTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Update the priority of a conversation') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:priority) + expect(tool.parameters[:priority].name).to eq(:priority) + expect(tool.parameters[:priority].type).to eq('string') + expect(tool.parameters[:priority].description).to eq('The priority level: low, medium, high, urgent, or nil to remove priority') + end + end + + describe '#perform' do + context 'when conversation exists' do + context 'with valid priority levels' do + %w[low medium high urgent].each do |priority| + it "updates conversation priority to #{priority}" do + result = tool.perform(tool_context, priority: priority) + expect(result).to eq("Priority updated to '#{priority}' for conversation ##{conversation.display_id}") + + expect(conversation.reload.priority).to eq(priority) + end + end + + it 'removes priority when set to nil' do + conversation.update!(priority: 'high') + + result = tool.perform(tool_context, priority: 'nil') + expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}") + + expect(conversation.reload.priority).to be_nil + end + + it 'removes priority when set to empty string' do + conversation.update!(priority: 'high') + + result = tool.perform(tool_context, priority: '') + expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}") + + expect(conversation.reload.priority).to be_nil + end + + it 'logs tool usage' do + expect(tool).to receive(:log_tool_usage).with( + 'update_priority', + { conversation_id: conversation.id, priority: 'high' } + ) + + tool.perform(tool_context, priority: 'high') + end + end + + context 'with invalid priority levels' do + it 'returns error message for invalid priority' do + result = tool.perform(tool_context, priority: 'invalid') + expect(result).to eq('Invalid priority. Valid options: low, medium, high, urgent, nil') + end + + it 'does not update conversation priority' do + original_priority = conversation.priority + + tool.perform(tool_context, priority: 'invalid') + + expect(conversation.reload.priority).to eq(original_priority) + end + end + end + + context 'when conversation does not exist' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, priority: 'high') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, priority: 'high') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation id is nil' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, priority: 'high') + expect(result).to eq('Conversation not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb index 1f13a362b..7a39559c3 100644 --- a/spec/enterprise/models/captain/scenario_spec.rb +++ b/spec/enterprise/models/captain/scenario_spec.rb @@ -33,15 +33,119 @@ RSpec.describe Captain::Scenario, type: :model do let(:account) { create(:account) } let(:assistant) { create(:captain_assistant, account: account) } - describe 'before_save :populate_tools' do - it 'calls populate_tools before saving' do + describe 'before_save :resolve_tool_references' do + it 'calls resolve_tool_references before saving' do scenario = build(:captain_scenario, assistant: assistant, account: account) - expect(scenario).to receive(:populate_tools) + expect(scenario).to receive(:resolve_tool_references) scenario.save end end end + describe 'tool validation and population' do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + before do + # Mock available tools + allow(described_class).to receive(:available_tool_ids).and_return(%w[ + add_contact_note add_private_note update_priority + ]) + end + + describe 'validate_instruction_tools' do + it 'is valid with valid tool references' do + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Contact Note](tool://add_contact_note) to document') + + expect(scenario).to be_valid + end + + it 'is invalid with invalid tool references' do + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Invalid Tool](tool://invalid_tool) to process') + + expect(scenario).not_to be_valid + expect(scenario.errors[:instruction]).to include('contains invalid tools: invalid_tool') + end + + it 'is invalid with multiple invalid tools' do + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Invalid Tool](tool://invalid_tool) and [@Another Invalid](tool://another_invalid)') + + expect(scenario).not_to be_valid + expect(scenario.errors[:instruction]).to include('contains invalid tools: invalid_tool, another_invalid') + end + + it 'is valid with no tool references' do + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Just respond politely to the customer') + + expect(scenario).to be_valid + end + + it 'is valid with blank instruction' do + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: '') + + # Will be invalid due to presence validation, not tool validation + expect(scenario).not_to be_valid + expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/) + end + end + + describe 'resolve_tool_references' do + it 'populates tools array with referenced tool IDs' do + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)') + + expect(scenario.tools).to eq(%w[add_contact_note update_priority]) + end + + it 'sets tools to nil when no tools are referenced' do + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Just respond politely to the customer') + + expect(scenario.tools).to be_nil + end + + it 'handles duplicate tool references' do + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Contact Note](tool://add_contact_note) and [@Add Contact Note](tool://add_contact_note) again') + + expect(scenario.tools).to eq(['add_contact_note']) + end + + it 'updates tools when instruction changes' do + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Contact Note](tool://add_contact_note)') + + expect(scenario.tools).to eq(['add_contact_note']) + + scenario.update!(instruction: 'Use [@Update Priority](tool://update_priority) instead') + expect(scenario.tools).to eq(['update_priority']) + end + end + end + describe 'factory' do it 'creates a valid scenario with associations' do account = create(:account) diff --git a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb new file mode 100644 index 000000000..afe482385 --- /dev/null +++ b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb @@ -0,0 +1,180 @@ +require 'rails_helper' + +RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do + # Create a test class that includes the concern + let(:test_class) do + Class.new do + include Concerns::CaptainToolsHelpers + + def self.name + 'TestClass' + end + end + end + + let(:test_instance) { test_class.new } + + describe 'TOOL_REFERENCE_REGEX' do + it 'matches tool references in text' do + text = 'Use [@Add Contact Note](tool://add_contact_note) and [Update Priority](tool://update_priority)' + matches = text.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX) + + expect(matches.flatten).to eq(%w[add_contact_note update_priority]) + end + + it 'does not match invalid formats' do + invalid_formats = [ + '', + 'tool://invalid', + '(tool:invalid)', + '(tool://)', + '(tool://with/slash)', + '(tool://add_contact_note)', + '[@Tool](tool://)', + '[Tool](tool://with/slash)', + '[](tool://valid)' + ] + + invalid_formats.each do |format| + matches = format.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX) + expect(matches).to be_empty, "Should not match: #{format}" + end + end + end + + describe '.available_agent_tools' do + before do + # Mock the YAML file loading + allow(YAML).to receive(:load_file).and_return([ + { + 'id' => 'add_contact_note', + 'title' => 'Add Contact Note', + 'description' => 'Add a note to a contact', + 'icon' => 'note-add' + }, + { + 'id' => 'invalid_tool', + 'title' => 'Invalid Tool', + 'description' => 'This tool does not exist', + 'icon' => 'invalid' + } + ]) + + # Mock class resolution - only add_contact_note exists + allow(test_class).to receive(:resolve_tool_class) do |tool_id| + case tool_id + when 'add_contact_note' + Captain::Tools::AddContactNoteTool + end + end + end + + it 'returns only resolvable tools' do + tools = test_class.available_agent_tools + + expect(tools.length).to eq(1) + expect(tools.first).to eq({ + id: 'add_contact_note', + title: 'Add Contact Note', + description: 'Add a note to a contact', + icon: 'note-add' + }) + end + + it 'logs warnings for unresolvable tools' do + expect(Rails.logger).to receive(:warn).with('Tool class not found for ID: invalid_tool') + + test_class.available_agent_tools + end + + it 'memoizes the result' do + expect(YAML).to receive(:load_file).once.and_return([]) + + 2.times { test_class.available_agent_tools } + end + end + + describe '.resolve_tool_class' do + it 'resolves valid tool classes' do + # Mock the constantize to return a class + stub_const('Captain::Tools::AddContactNoteTool', Class.new) + + result = test_class.resolve_tool_class('add_contact_note') + expect(result).to eq(Captain::Tools::AddContactNoteTool) + end + + it 'returns nil for invalid tool classes' do + result = test_class.resolve_tool_class('invalid_tool') + expect(result).to be_nil + end + + it 'converts snake_case to PascalCase' do + stub_const('Captain::Tools::AddPrivateNoteTool', Class.new) + + result = test_class.resolve_tool_class('add_private_note') + expect(result).to eq(Captain::Tools::AddPrivateNoteTool) + end + end + + describe '.available_tool_ids' do + before do + allow(test_class).to receive(:available_agent_tools).and_return([ + { id: 'add_contact_note', title: 'Add Contact Note', description: '...', + icon: 'note' }, + { id: 'update_priority', title: 'Update Priority', description: '...', + icon: 'priority' } + ]) + end + + it 'returns array of tool IDs' do + ids = test_class.available_tool_ids + expect(ids).to eq(%w[add_contact_note update_priority]) + end + + it 'memoizes the result' do + expect(test_class).to receive(:available_agent_tools).once.and_return([]) + + 2.times { test_class.available_tool_ids } + end + end + + describe '#extract_tool_ids_from_text' do + it 'extracts tool IDs from text' do + text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)' + result = test_instance.extract_tool_ids_from_text(text) + + expect(result).to eq(%w[add_contact_note update_priority]) + end + + it 'returns unique tool IDs' do + text = 'Use [@Add Contact Note](tool://add_contact_note) and [@Contact Note](tool://add_contact_note) again' + result = test_instance.extract_tool_ids_from_text(text) + + expect(result).to eq(['add_contact_note']) + end + + it 'returns empty array for blank text' do + expect(test_instance.extract_tool_ids_from_text('')).to eq([]) + expect(test_instance.extract_tool_ids_from_text(nil)).to eq([]) + expect(test_instance.extract_tool_ids_from_text(' ')).to eq([]) + end + + it 'returns empty array when no tools found' do + text = 'This text has no tool references' + result = test_instance.extract_tool_ids_from_text(text) + + expect(result).to eq([]) + end + + it 'handles complex text with multiple tools' do + text = <<~TEXT + Start with [@Add Contact Note](tool://add_contact_note) to document. + Then use [@Update Priority](tool://update_priority) if needed. + Finally [@Add Private Note](tool://add_private_note) for internal notes. + TEXT + + result = test_instance.extract_tool_ids_from_text(text) + expect(result).to eq(%w[add_contact_note update_priority add_private_note]) + end + end +end