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.
This commit is contained in:
Shivam Mishra
2025-10-03 15:53:48 +05:30
parent 99eb27706b
commit ccda8a77e5
2 changed files with 195 additions and 3 deletions
+21 -3
View File
@@ -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?
@@ -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)