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/config/features.yml b/config/features.yml index eed6a9da1..41515ff64 100644 --- a/config/features.yml +++ b/config/features.yml @@ -191,7 +191,6 @@ - name: assignment_v2 display_name: Assignment V2 enabled: false - chatwoot_internal: true - name: twilio_content_templates display_name: Twilio Content Templates enabled: false 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/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 4f039d57a..08bff5ef3 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -106,7 +106,7 @@ class Captain::Assistant < ApplicationRecord scenarios: scenarios.enabled.map do |scenario| { title: scenario.title, - key: scenario.title.parameterize.underscore, + key: scenario.handoff_key, description: scenario.description } end, diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb index c43468804..8a6a3c979 100644 --- a/enterprise/app/models/captain/scenario.rb +++ b/enterprise/app/models/captain/scenario.rb @@ -24,6 +24,19 @@ class Captain::Scenario < ApplicationRecord include Concerns::CaptainToolsHelpers include Concerns::Agentable + # OpenAI enforces a 64-char limit on function names. The ai-agents gem + # prepends "handoff_to_" (11 chars), so we keep a safety margin and cap + # the full tool name to MAX_HANDOFF_TOOL_NAME_LENGTH (60 chars). + # Format: "scenario_{id}_{slug}_agent" for persisted records (stable + readable), + # and "scenario_draft_{slug}_agent" for unsaved records, with slug truncated + # based on the available length budget. + HANDOFF_TOOL_PREFIX = 'handoff_to_'.freeze + HANDOFF_KEY_PREFIX = 'scenario'.freeze + HANDOFF_KEY_SUFFIX = 'agent'.freeze + MAX_HANDOFF_TOOL_NAME_LENGTH = 60 + MAX_AGENT_NAME_LENGTH = MAX_HANDOFF_TOOL_NAME_LENGTH - HANDOFF_TOOL_PREFIX.length + MAX_HANDOFF_SLUG_LENGTH = 24 + self.table_name = 'captain_scenarios' belongs_to :assistant, class_name: 'Captain::Assistant' @@ -42,6 +55,10 @@ class Captain::Scenario < ApplicationRecord before_save :resolve_tool_references + def handoff_key + [handoff_id_key, compact_handoff_slug, HANDOFF_KEY_SUFFIX].compact.join('_') + end + def prompt_context { title: title, @@ -56,7 +73,28 @@ class Captain::Scenario < ApplicationRecord private def agent_name - "#{title} Agent".parameterize(separator: '_') + handoff_key + end + + def handoff_id_key + return "#{HANDOFF_KEY_PREFIX}_#{id}" if id.present? + + "#{HANDOFF_KEY_PREFIX}_draft" + end + + def compact_handoff_slug + slug = title.to_s.parameterize(separator: '_').presence + return nil if slug.blank? + + max_slug_length = [MAX_HANDOFF_SLUG_LENGTH, dynamic_slug_max_length].min + return nil if max_slug_length <= 0 + + slug.first(max_slug_length).sub(/_+\z/, '').presence + end + + def dynamic_slug_max_length + # handoff_to_#{scenario___agent} + MAX_AGENT_NAME_LENGTH - handoff_id_key.length - HANDOFF_KEY_SUFFIX.length - 2 end def agent_tools 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 diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb index 163581f01..94ce5325a 100644 --- a/spec/enterprise/models/captain/scenario_spec.rb +++ b/spec/enterprise/models/captain/scenario_spec.rb @@ -42,6 +42,45 @@ RSpec.describe Captain::Scenario, type: :model do end end + describe '#handoff_key' do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + it 'uses id plus readable slug for persisted scenarios' do + scenario = create(:captain_scenario, assistant: assistant, account: account, + title: 'Handle complex refund requests requiring manager approval steps') + + expect(scenario.handoff_key).to start_with("scenario_#{scenario.id}_") + expect(scenario.handoff_key).to end_with('_agent') + expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60 + end + + it 'uses a truncated slug key for unsaved scenarios' do + scenario = build(:captain_scenario, assistant: assistant, account: account, + title: 'Troubleshoot payment gateway errors for recurring subscription charges') + + expect(scenario.handoff_key).to match(/\Ascenario_draft_[a-z0-9_]+_agent\z/) + expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60 + end + + it 'stays within length budget even for large ids' do + scenario = build(:captain_scenario, assistant: assistant, account: account, + title: 'A very long scenario title used only for budget verification') + allow(scenario).to receive(:id).and_return(1_234_567_890_123_456_789) + + expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60 + end + + it 'exposes handoff keys in assistant prompt context' do + scenario = create(:captain_scenario, assistant: assistant, account: account) + + prompt_context = assistant.send(:prompt_context) + scenario_config = prompt_context[:scenarios].find { |entry| entry[:title] == scenario.title } + + expect(scenario_config[:key]).to eq(scenario.handoff_key) + end + end + describe 'tool validation and population' do let(:account) { create(:account) } let(:assistant) { create(:captain_assistant, account: account) }