From ccda8a77e5385c9f372598b7ea161877358aaa28 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 3 Oct 2025 15:53:48 +0530 Subject: [PATCH] feat: integrate custom tools with Captain scenarios Add support for custom HTTP tools in scenario tool resolution and validation: - Extend resolved_tools to load custom tool metadata from database - Add resolve_tool_instance method to instantiate HttpTool for custom tools - Update validation to include account-scoped custom tools in available tools - Support mixed static and custom tool references in scenarios - Filter out disabled custom tools at resolution time Test coverage includes validation, resolution, and execution paths for custom tools with account isolation and enable/disable states. --- enterprise/app/models/captain/scenario.rb | 24 ++- .../models/captain/scenario_spec.rb | 174 ++++++++++++++++++ 2 files changed, 195 insertions(+), 3 deletions(-) diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb index aac7e2411..af49a8470 100644 --- a/enterprise/app/models/captain/scenario.rb +++ b/enterprise/app/models/captain/scenario.rb @@ -57,7 +57,7 @@ class Captain::Scenario < ApplicationRecord end def agent_tools - resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) } + resolved_tools.map { |tool| resolve_tool_instance(tool[:id]) } end def resolved_instructions @@ -71,7 +71,22 @@ class Captain::Scenario < ApplicationRecord available_tools = self.class.available_agent_tools tools.filter_map do |tool_id| - available_tools.find { |tool| tool[:id] == tool_id } + if tool_id.start_with?('custom_') + custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true) + custom_tool&.to_tool_metadata + else + available_tools.find { |tool| tool[:id] == tool_id } + end + end + end + + def resolve_tool_instance(tool_id) + if tool_id.start_with?('custom_') + custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true) + custom_tool&.tool(assistant) + else + tool_class = self.class.resolve_tool_class(tool_id) + tool_class&.new(assistant) end end @@ -96,7 +111,10 @@ class Captain::Scenario < ApplicationRecord return if tool_ids.empty? available_tool_ids = self.class.available_tool_ids - invalid_tools = tool_ids - available_tool_ids + custom_tool_ids = Captain::CustomTool.where(account_id: account_id, enabled: true).pluck(:slug) + all_available_tool_ids = available_tool_ids + custom_tool_ids + + invalid_tools = tool_ids - all_available_tool_ids return unless invalid_tools.any? diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb index 7a39559c3..dca0a6fbc 100644 --- a/spec/enterprise/models/captain/scenario_spec.rb +++ b/spec/enterprise/models/captain/scenario_spec.rb @@ -102,6 +102,49 @@ RSpec.describe Captain::Scenario, type: :model do expect(scenario).not_to be_valid expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/) end + + it 'is valid with custom tool references' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details') + + expect(scenario).to be_valid + end + + it 'is invalid with custom tool from different account' do + other_account = create(:account) + create(:captain_custom_tool, account: other_account, slug: 'custom_fetch-order') + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details') + + expect(scenario).not_to be_valid + expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order') + end + + it 'is invalid with disabled custom tool' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false) + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details') + + expect(scenario).not_to be_valid + expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order') + end + + it 'is valid with mixed static and custom tool references' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)') + + expect(scenario).to be_valid + end end describe 'resolve_tool_references' do @@ -146,6 +189,137 @@ RSpec.describe Captain::Scenario, type: :model do end end + describe 'custom tool integration' do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + before do + allow(described_class).to receive(:available_tool_ids).and_return(%w[add_contact_note]) + allow(described_class).to receive(:available_agent_tools).and_return([ + { id: 'add_contact_note', title: 'Add Contact Note', + description: 'Add a note' } + ]) + end + + describe '#resolved_tools' do + it 'includes custom tool metadata' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', + title: 'Fetch Order', description: 'Gets order details') + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') + + resolved = scenario.send(:resolved_tools) + expect(resolved.length).to eq(1) + expect(resolved.first[:id]).to eq('custom_fetch-order') + expect(resolved.first[:title]).to eq('Fetch Order') + expect(resolved.first[:description]).to eq('Gets order details') + end + + it 'includes both static and custom tools' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)') + + resolved = scenario.send(:resolved_tools) + expect(resolved.length).to eq(2) + expect(resolved.map { |t| t[:id] }).to contain_exactly('add_contact_note', 'custom_fetch-order') + end + + it 'excludes disabled custom tools' do + custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true) + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') + + custom_tool.update!(enabled: false) + + resolved = scenario.send(:resolved_tools) + expect(resolved).to be_empty + end + end + + describe '#resolve_tool_instance' do + it 'returns HttpTool instance for custom tools' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = create(:captain_scenario, assistant: assistant, account: account) + + tool_instance = scenario.send(:resolve_tool_instance, 'custom_fetch-order') + expect(tool_instance).to be_a(Captain::Tools::HttpTool) + end + + it 'returns nil for disabled custom tools' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false) + scenario = create(:captain_scenario, assistant: assistant, account: account) + + tool_instance = scenario.send(:resolve_tool_instance, 'custom_fetch-order') + expect(tool_instance).to be_nil + end + + it 'returns static tool instance for non-custom tools' do + scenario = create(:captain_scenario, assistant: assistant, account: account) + allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return( + Class.new do + def initialize(_assistant); end + end + ) + + tool_instance = scenario.send(:resolve_tool_instance, 'add_contact_note') + expect(tool_instance).not_to be_nil + expect(tool_instance).not_to be_a(Captain::Tools::HttpTool) + end + end + + describe '#agent_tools' do + it 'returns array of tool instances including custom tools' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') + + tools = scenario.send(:agent_tools) + expect(tools.length).to eq(1) + expect(tools.first).to be_a(Captain::Tools::HttpTool) + end + + it 'excludes disabled custom tools from execution' do + custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true) + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') + + custom_tool.update!(enabled: false) + + tools = scenario.send(:agent_tools) + expect(tools).to be_empty + end + + it 'returns mixed static and custom tool instances' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)') + + allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return( + Class.new do + def initialize(_assistant); end + end + ) + + tools = scenario.send(:agent_tools) + expect(tools.length).to eq(2) + expect(tools.last).to be_a(Captain::Tools::HttpTool) + end + end + end + describe 'factory' do it 'creates a valid scenario with associations' do account = create(:account)