Merge branch 'develop' into fix/enforce-inbox-limit-on-all-creation-paths
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user