Merge branch 'develop' into fix/enforce-inbox-limit-on-all-creation-paths

This commit is contained in:
Muhsin Keloth
2026-03-11 12:40:38 +04:00
committed by GitHub
10 changed files with 199 additions and 33 deletions
@@ -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) {
-1
View File
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -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,
+39 -1
View File
@@ -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_<id>_<slug>_agent}
MAX_AGENT_NAME_LENGTH - handoff_id_key.length - HANDOFF_KEY_SUFFIX.length - 2
end
def agent_tools
@@ -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)
@@ -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 = ''
@@ -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
@@ -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) }