feat: include chatwoot metadata with each tool call (#12907)
This commit is contained in:
@@ -66,6 +66,31 @@ module Concerns::Toolable
|
|||||||
[auth_config['username'], auth_config['password']]
|
[auth_config['username'], auth_config['password']]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_metadata_headers(state)
|
||||||
|
{}.tap do |headers|
|
||||||
|
add_base_headers(headers, state)
|
||||||
|
add_conversation_headers(headers, state[:conversation]) if state[:conversation]
|
||||||
|
add_contact_headers(headers, state[:contact]) if state[:contact]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_base_headers(headers, state)
|
||||||
|
headers['X-Chatwoot-Account-Id'] = state[:account_id].to_s if state[:account_id]
|
||||||
|
headers['X-Chatwoot-Assistant-Id'] = state[:assistant_id].to_s if state[:assistant_id]
|
||||||
|
headers['X-Chatwoot-Tool-Slug'] = slug if slug.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_conversation_headers(headers, conversation)
|
||||||
|
headers['X-Chatwoot-Conversation-Id'] = conversation[:id].to_s if conversation[:id]
|
||||||
|
headers['X-Chatwoot-Conversation-Display-Id'] = conversation[:display_id].to_s if conversation[:display_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_contact_headers(headers, contact)
|
||||||
|
headers['X-Chatwoot-Contact-Id'] = contact[:id].to_s if contact[:id]
|
||||||
|
headers['X-Chatwoot-Contact-Email'] = contact[:email].to_s if contact[:email].present?
|
||||||
|
headers['X-Chatwoot-Contact-Phone'] = contact[:phone_number].to_s if contact[:phone_number].present?
|
||||||
|
end
|
||||||
|
|
||||||
def format_response(raw_response_body)
|
def format_response(raw_response_body)
|
||||||
return raw_response_body if response_template.blank?
|
return raw_response_body if response_template.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ class Captain::Tools::HttpTool < Agents::Tool
|
|||||||
@custom_tool.enabled?
|
@custom_tool.enabled?
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform(_tool_context, **params)
|
def perform(tool_context, **params)
|
||||||
url = @custom_tool.build_request_url(params)
|
url = @custom_tool.build_request_url(params)
|
||||||
body = @custom_tool.build_request_body(params)
|
body = @custom_tool.build_request_body(params)
|
||||||
|
|
||||||
response = execute_http_request(url, body)
|
response = execute_http_request(url, body, tool_context)
|
||||||
@custom_tool.format_response(response.body)
|
@custom_tool.format_response(response.body)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
||||||
@@ -39,7 +39,7 @@ class Captain::Tools::HttpTool < Agents::Tool
|
|||||||
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
|
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
|
||||||
MAX_RESPONSE_SIZE = 1.megabyte
|
MAX_RESPONSE_SIZE = 1.megabyte
|
||||||
|
|
||||||
def execute_http_request(url, body)
|
def execute_http_request(url, body, tool_context)
|
||||||
uri = URI.parse(url)
|
uri = URI.parse(url)
|
||||||
|
|
||||||
# Check if resolved IP is private
|
# Check if resolved IP is private
|
||||||
@@ -53,6 +53,7 @@ class Captain::Tools::HttpTool < Agents::Tool
|
|||||||
|
|
||||||
request = build_http_request(uri, body)
|
request = build_http_request(uri, body)
|
||||||
apply_authentication(request)
|
apply_authentication(request)
|
||||||
|
apply_metadata_headers(request, tool_context)
|
||||||
|
|
||||||
response = http.request(request)
|
response = http.request(request)
|
||||||
|
|
||||||
@@ -102,4 +103,10 @@ class Captain::Tools::HttpTool < Agents::Tool
|
|||||||
credentials = @custom_tool.build_basic_auth_credentials
|
credentials = @custom_tool.build_basic_auth_credentials
|
||||||
request.basic_auth(*credentials) if credentials
|
request.basic_auth(*credentials) if credentials
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def apply_metadata_headers(request, tool_context)
|
||||||
|
state = tool_context&.state || {}
|
||||||
|
metadata_headers = @custom_tool.build_metadata_headers(state)
|
||||||
|
metadata_headers.each { |key, value| request[key] = value }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -237,5 +237,135 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
|||||||
expect(result).to eq('Created order #ORD-789 for Widget')
|
expect(result).to eq('Created order #ORD-789 for Widget')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with metadata headers' do
|
||||||
|
let(:conversation) { create(:conversation, account: account) }
|
||||||
|
let(:contact) { conversation.contact }
|
||||||
|
let(:tool_context_with_state) do
|
||||||
|
Struct.new(:state).new({
|
||||||
|
account_id: account.id,
|
||||||
|
assistant_id: assistant.id,
|
||||||
|
conversation: {
|
||||||
|
id: conversation.id,
|
||||||
|
display_id: conversation.display_id
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
id: contact.id,
|
||||||
|
email: contact.email,
|
||||||
|
phone_number: contact.phone_number
|
||||||
|
}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
custom_tool.update!(
|
||||||
|
endpoint_url: 'https://example.com/api/data',
|
||||||
|
response_template: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes metadata headers in GET request' do
|
||||||
|
stub_request(:get, 'https://example.com/api/data')
|
||||||
|
.with(headers: {
|
||||||
|
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||||
|
'X-Chatwoot-Assistant-Id' => assistant.id.to_s,
|
||||||
|
'X-Chatwoot-Tool-Slug' => custom_tool.slug,
|
||||||
|
'X-Chatwoot-Conversation-Id' => conversation.id.to_s,
|
||||||
|
'X-Chatwoot-Conversation-Display-Id' => conversation.display_id.to_s,
|
||||||
|
'X-Chatwoot-Contact-Id' => contact.id.to_s,
|
||||||
|
'X-Chatwoot-Contact-Email' => contact.email
|
||||||
|
})
|
||||||
|
.to_return(status: 200, body: '{"success": true}')
|
||||||
|
|
||||||
|
tool.perform(tool_context_with_state)
|
||||||
|
|
||||||
|
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||||
|
.with(headers: {
|
||||||
|
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||||
|
'X-Chatwoot-Contact-Email' => contact.email
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes metadata headers in POST request' do
|
||||||
|
custom_tool.update!(http_method: 'POST', request_template: '{"data": "test"}')
|
||||||
|
|
||||||
|
stub_request(:post, 'https://example.com/api/data')
|
||||||
|
.with(
|
||||||
|
body: '{"data": "test"}',
|
||||||
|
headers: {
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||||
|
'X-Chatwoot-Tool-Slug' => custom_tool.slug,
|
||||||
|
'X-Chatwoot-Contact-Email' => contact.email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.to_return(status: 200, body: '{"success": true}')
|
||||||
|
|
||||||
|
tool.perform(tool_context_with_state)
|
||||||
|
|
||||||
|
expect(WebMock).to have_requested(:post, 'https://example.com/api/data')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes metadata headers along with authentication headers' do
|
||||||
|
custom_tool.update!(
|
||||||
|
auth_type: 'bearer',
|
||||||
|
auth_config: { 'token' => 'test_token' }
|
||||||
|
)
|
||||||
|
|
||||||
|
stub_request(:get, 'https://example.com/api/data')
|
||||||
|
.with(headers: {
|
||||||
|
'Authorization' => 'Bearer test_token',
|
||||||
|
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||||
|
'X-Chatwoot-Contact-Id' => contact.id.to_s
|
||||||
|
})
|
||||||
|
.to_return(status: 200, body: '{"success": true}')
|
||||||
|
|
||||||
|
tool.perform(tool_context_with_state)
|
||||||
|
|
||||||
|
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||||
|
.with(headers: {
|
||||||
|
'Authorization' => 'Bearer test_token',
|
||||||
|
'X-Chatwoot-Contact-Id' => contact.id.to_s
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles missing contact in tool context' do
|
||||||
|
tool_context_no_contact = Struct.new(:state).new({
|
||||||
|
account_id: account.id,
|
||||||
|
assistant_id: assistant.id,
|
||||||
|
conversation: {
|
||||||
|
id: conversation.id,
|
||||||
|
display_id: conversation.display_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
stub_request(:get, 'https://example.com/api/data')
|
||||||
|
.with(headers: {
|
||||||
|
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||||
|
'X-Chatwoot-Conversation-Id' => conversation.id.to_s
|
||||||
|
})
|
||||||
|
.to_return(status: 200, body: '{"success": true}')
|
||||||
|
|
||||||
|
tool.perform(tool_context_no_contact)
|
||||||
|
|
||||||
|
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes contact phone when present' do
|
||||||
|
contact.update!(phone_number: '+1234567890')
|
||||||
|
tool_context_with_state.state[:contact][:phone_number] = '+1234567890'
|
||||||
|
|
||||||
|
stub_request(:get, 'https://example.com/api/data')
|
||||||
|
.with(headers: {
|
||||||
|
'X-Chatwoot-Contact-Phone' => '+1234567890'
|
||||||
|
})
|
||||||
|
.to_return(status: 200, body: '{"success": true}')
|
||||||
|
|
||||||
|
tool.perform(tool_context_with_state)
|
||||||
|
|
||||||
|
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||||
|
.with(headers: { 'X-Chatwoot-Contact-Phone' => '+1234567890' })
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -327,6 +327,98 @@ RSpec.describe Captain::CustomTool, type: :model do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#build_metadata_headers' do
|
||||||
|
let(:tool) { create(:captain_custom_tool, account: account, slug: 'custom_test_tool') }
|
||||||
|
let(:conversation) { create(:conversation, account: account) }
|
||||||
|
let(:contact) { conversation.contact }
|
||||||
|
|
||||||
|
let(:state) do
|
||||||
|
{
|
||||||
|
account_id: account.id,
|
||||||
|
assistant_id: 123,
|
||||||
|
conversation: {
|
||||||
|
id: conversation.id,
|
||||||
|
display_id: conversation.display_id
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
id: contact.id,
|
||||||
|
email: contact.email,
|
||||||
|
phone_number: contact.phone_number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes account and assistant metadata' do
|
||||||
|
headers = tool.build_metadata_headers(state)
|
||||||
|
|
||||||
|
expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s)
|
||||||
|
expect(headers['X-Chatwoot-Assistant-Id']).to eq('123')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes tool slug' do
|
||||||
|
headers = tool.build_metadata_headers(state)
|
||||||
|
|
||||||
|
expect(headers['X-Chatwoot-Tool-Slug']).to eq('custom_test_tool')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes conversation metadata when present' do
|
||||||
|
headers = tool.build_metadata_headers(state)
|
||||||
|
|
||||||
|
expect(headers['X-Chatwoot-Conversation-Id']).to eq(conversation.id.to_s)
|
||||||
|
expect(headers['X-Chatwoot-Conversation-Display-Id']).to eq(conversation.display_id.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes contact metadata when present' do
|
||||||
|
headers = tool.build_metadata_headers(state)
|
||||||
|
|
||||||
|
expect(headers['X-Chatwoot-Contact-Id']).to eq(contact.id.to_s)
|
||||||
|
expect(headers['X-Chatwoot-Contact-Email']).to eq(contact.email)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles missing conversation gracefully' do
|
||||||
|
state[:conversation] = nil
|
||||||
|
|
||||||
|
headers = tool.build_metadata_headers(state)
|
||||||
|
|
||||||
|
expect(headers['X-Chatwoot-Conversation-Id']).to be_nil
|
||||||
|
expect(headers['X-Chatwoot-Conversation-Display-Id']).to be_nil
|
||||||
|
expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles missing contact gracefully' do
|
||||||
|
state[:contact] = nil
|
||||||
|
|
||||||
|
headers = tool.build_metadata_headers(state)
|
||||||
|
|
||||||
|
expect(headers['X-Chatwoot-Contact-Id']).to be_nil
|
||||||
|
expect(headers['X-Chatwoot-Contact-Email']).to be_nil
|
||||||
|
expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles empty state' do
|
||||||
|
headers = tool.build_metadata_headers({})
|
||||||
|
|
||||||
|
expect(headers).to be_a(Hash)
|
||||||
|
expect(headers['X-Chatwoot-Tool-Slug']).to eq('custom_test_tool')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'omits contact email header when email is blank' do
|
||||||
|
state[:contact][:email] = ''
|
||||||
|
|
||||||
|
headers = tool.build_metadata_headers(state)
|
||||||
|
|
||||||
|
expect(headers).not_to have_key('X-Chatwoot-Contact-Email')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'omits contact phone header when phone number is blank' do
|
||||||
|
state[:contact][:phone_number] = ''
|
||||||
|
|
||||||
|
headers = tool.build_metadata_headers(state)
|
||||||
|
|
||||||
|
expect(headers).not_to have_key('X-Chatwoot-Contact-Phone')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe '#to_tool_metadata' do
|
describe '#to_tool_metadata' do
|
||||||
it 'returns tool metadata hash with custom flag' do
|
it 'returns tool metadata hash with custom flag' do
|
||||||
tool = create(:captain_custom_tool, account: account,
|
tool = create(:captain_custom_tool, account: account,
|
||||||
|
|||||||
Reference in New Issue
Block a user