From 0d8e249fe488ea230f5a76c57a182563b4751834 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 10 Dec 2025 15:25:18 +0530 Subject: [PATCH] feat: include chatwoot metadata with each tool call (#12907) --- enterprise/app/models/concerns/toolable.rb | 25 ++++ enterprise/lib/captain/tools/http_tool.rb | 13 +- .../lib/captain/tools/http_tool_spec.rb | 130 ++++++++++++++++++ .../models/captain/custom_tool_spec.rb | 92 +++++++++++++ 4 files changed, 257 insertions(+), 3 deletions(-) diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb index ad047e8f8..51ec1be3e 100644 --- a/enterprise/app/models/concerns/toolable.rb +++ b/enterprise/app/models/concerns/toolable.rb @@ -66,6 +66,31 @@ module Concerns::Toolable [auth_config['username'], auth_config['password']] 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) return raw_response_body if response_template.blank? diff --git a/enterprise/lib/captain/tools/http_tool.rb b/enterprise/lib/captain/tools/http_tool.rb index b634de04e..b4593d27f 100644 --- a/enterprise/lib/captain/tools/http_tool.rb +++ b/enterprise/lib/captain/tools/http_tool.rb @@ -11,11 +11,11 @@ class Captain::Tools::HttpTool < Agents::Tool @custom_tool.enabled? end - def perform(_tool_context, **params) + def perform(tool_context, **params) url = @custom_tool.build_request_url(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) rescue StandardError => e 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 MAX_RESPONSE_SIZE = 1.megabyte - def execute_http_request(url, body) + def execute_http_request(url, body, tool_context) uri = URI.parse(url) # Check if resolved IP is private @@ -53,6 +53,7 @@ class Captain::Tools::HttpTool < Agents::Tool request = build_http_request(uri, body) apply_authentication(request) + apply_metadata_headers(request, tool_context) response = http.request(request) @@ -102,4 +103,10 @@ class Captain::Tools::HttpTool < Agents::Tool credentials = @custom_tool.build_basic_auth_credentials request.basic_auth(*credentials) if credentials 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 diff --git a/spec/enterprise/lib/captain/tools/http_tool_spec.rb b/spec/enterprise/lib/captain/tools/http_tool_spec.rb index d48af2752..967a10574 100644 --- a/spec/enterprise/lib/captain/tools/http_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/http_tool_spec.rb @@ -237,5 +237,135 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do expect(result).to eq('Created order #ORD-789 for Widget') 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 diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb index 5f6c7b19a..f936eeaa5 100644 --- a/spec/enterprise/models/captain/custom_tool_spec.rb +++ b/spec/enterprise/models/captain/custom_tool_spec.rb @@ -327,6 +327,98 @@ RSpec.describe Captain::CustomTool, type: :model do 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 it 'returns tool metadata hash with custom flag' do tool = create(:captain_custom_tool, account: account,