diff --git a/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue b/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue index 69cbb9be5..51aae6477 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue @@ -18,10 +18,18 @@ const newMessage = ref(''); const isLoading = ref(false); const formatMessagesForApi = () => { - return messages.value.map(message => ({ - role: message.sender, - content: message.content, - })); + return messages.value.map(message => { + const payload = { + role: message.sender, + content: message.content, + }; + + if (message.sender === 'assistant' && message.agentName) { + payload.agent_name = message.agentName; + } + + return payload; + }); }; const resetConversation = () => { @@ -62,6 +70,7 @@ const sendMessage = async () => { messages.value.push({ content: data.response, sender: 'assistant', + agentName: data.agent_name, timestamp: new Date().toISOString(), }); } catch (error) { 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 ebeaaf67f..c1ac4b98b 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -24,10 +24,16 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base end def playground - response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response( - additional_message: params[:message_content], - message_history: message_history - ) + response = if captain_v2_enabled? + Captain::Assistant::AgentRunnerService.new(assistant: @assistant, source: 'playground').generate_response( + message_history: playground_message_history + ) + else + Captain::Llm::AssistantChatService.new(assistant: @assistant, source: 'playground').generate_response( + additional_message: playground_params[:message_content], + message_history: message_history + ) + end render json: response end @@ -64,10 +70,31 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base end def playground_params - params.require(:assistant).permit(:message_content, message_history: [:role, :content]) + params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name]) end def message_history - (playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } } + (playground_params[:message_history] || []).map do |message| + { + role: message[:role], + content: message[:content], + agent_name: message[:agent_name] + }.compact + end + end + + def playground_message_history + history = message_history + current_message = playground_params[:message_content] + return history if current_message.blank? + + current_user_message = { role: 'user', content: current_message } + return history if history.last == current_user_message + + history + [current_user_message] + end + + def captain_v2_enabled? + @assistant.account.feature_enabled?('captain_integration_v2') end end diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb index d5ac6df33..265f54d69 100644 --- a/enterprise/app/helpers/captain/chat_helper.rb +++ b/enterprise/app/helpers/captain/chat_helper.rb @@ -5,7 +5,6 @@ module Captain::ChatHelper def request_chat_completion log_chat_completion_request - chat = build_chat add_messages_to_chat(chat) @@ -86,7 +85,8 @@ module Captain::ChatHelper temperature: temperature, metadata: { assistant_id: @assistant&.id, - channel_type: resolved_channel_type + channel_type: resolved_channel_type, + source: @source }.compact } end @@ -130,7 +130,6 @@ module Captain::ChatHelper end def log_chat_completion_request - Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion " \ - "for messages #{@messages} with #{@tools&.length || 0} tools") + Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, requesting completion for #{@messages} with #{@tools&.length || 0} tools") end end diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index bdf35e98e..1875a9953 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -19,11 +19,11 @@ class Captain::Assistant::AgentRunnerService CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze - - def initialize(assistant:, conversation: nil, callbacks: {}) + def initialize(assistant:, conversation: nil, callbacks: {}, source: nil) @assistant = assistant @conversation = conversation @callbacks = callbacks + @source = source end def generate_response(message_history: []) @@ -32,8 +32,7 @@ class Captain::Assistant::AgentRunnerService process_agent_result(result) rescue StandardError => e - # when running the agent runner service in a rake task, the conversation might not have an account associated - # for regular production usage, it will run just fine + # In rake/local runs, conversation may not be present, so account is optional here. ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}" Rails.logger.error e.backtrace.join("\n") @@ -128,6 +127,7 @@ class Captain::Assistant::AgentRunnerService assistant_id: @assistant.id, assistant_config: @assistant.config } + state[:source] = @source if @source.present? build_conversation_state(state) if @conversation state @@ -140,8 +140,7 @@ class Captain::Assistant::AgentRunnerService state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign return unless @conversation.contact_inbox - state[:contact_inbox] = - @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES) + state[:contact_inbox] = @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES) end def build_and_wire_agents @@ -180,6 +179,7 @@ class Captain::Assistant::AgentRunnerService format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id], format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id], format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type], + format(ATTR_LANGFUSE_METADATA, 'source') => state[:source], ATTR_LANGFUSE_TRACE_INPUT => trace_input, ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input }.compact.transform_values(&:to_s) diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 5a2976e39..c1403ed1a 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -1,11 +1,12 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService include Captain::ChatHelper - def initialize(assistant: nil, conversation_id: nil) + def initialize(assistant: nil, conversation_id: nil, source: nil) super() @assistant = assistant @conversation_id = conversation_id + @source = source @messages = [system_message] @response = '' diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb index 24deb98dd..4689defaf 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb @@ -259,10 +259,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do message_content: 'Hello assistant', message_history: [ { role: 'user', content: 'Previous message' }, - { role: 'assistant', content: 'Previous response' } + { role: 'assistant', content: 'Previous response', agent_name: 'billing_scenario' } ] } end + let(:chat_service) { instance_double(Captain::Llm::AssistantChatService) } + let(:agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) } context 'when it is an un-authenticated user' do it 'returns unauthorized' do @@ -274,11 +276,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do end end - context 'when it is an agent' do - it 'generates a response' do - chat_service = instance_double(Captain::Llm::AssistantChatService) - allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service) + context 'when captain v2 is disabled' do + it 'generates a response with the legacy assistant chat service' do + allow(Captain::Llm::AssistantChatService).to receive(:new).with( + assistant: assistant, + source: 'playground' + ).and_return(chat_service) allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' }) + expect(Captain::Assistant::AgentRunnerService).not_to receive(:new) post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground", params: valid_params, @@ -292,14 +297,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do ) expect(json_response[:content]).to eq('Assistant response') end - end - context 'when message_history is not provided' do it 'uses empty array as default' do params_without_history = { message_content: 'Hello assistant' } - chat_service = instance_double(Captain::Llm::AssistantChatService) - allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service) + allow(Captain::Llm::AssistantChatService).to receive(:new).with( + assistant: assistant, + source: 'playground' + ).and_return(chat_service) allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' }) + expect(Captain::Assistant::AgentRunnerService).not_to receive(:new) post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground", params: params_without_history, @@ -313,5 +319,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do ) end end + + context 'when captain v2 is enabled' do + before do + account.enable_features('captain_integration_v2') + end + + it 'generates a response with the agent runner service' do + allow(Captain::Assistant::AgentRunnerService).to receive(:new).with( + assistant: assistant, + source: 'playground' + ).and_return(agent_runner_service) + allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' }) + expect(Captain::Llm::AssistantChatService).not_to receive(:new) + + post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground", + params: valid_params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(agent_runner_service).to have_received(:generate_response).with( + message_history: valid_params[:message_history] + [{ role: 'user', content: valid_params[:message_content] }] + ) + expect(json_response[:response]).to eq('Assistant response') + end + + it 'does not duplicate the latest user message if it is already in history' do + params_with_latest_message = { + message_content: 'Hello assistant', + message_history: [{ role: 'user', content: 'Hello assistant' }] + } + allow(Captain::Assistant::AgentRunnerService).to receive(:new).with( + assistant: assistant, + source: 'playground' + ).and_return(agent_runner_service) + allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' }) + + post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground", + params: params_with_latest_message, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(agent_runner_service).to have_received(:generate_response).with( + message_history: params_with_latest_message[:message_history] + ) + end + end end end