From de8aa48b8317b98c7a4b468e38db495dc083bd9c Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Wed, 11 Mar 2026 13:46:53 +0530
Subject: [PATCH 1/3] feat: make assignment_v2 feature available to all
accounts (#13764)
## Description
Makes the assignment_v2 feature flag available to all installations by
removing the chatwoot_internal restriction. Previously this feature was
hidden from self-hosted installations; this change surfaces it in the
feature flags UI so any Chatwoot instance can enable it.
## Type of change
- [ ] New feature (non-breaking change which adds functionality)
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
config/features.yml | 1 -
1 file changed, 1 deletion(-)
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
From dbe35252bc0c19de252d067905c529b65c02c56f Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Wed, 11 Mar 2026 14:01:25 +0530
Subject: [PATCH 2/3] fix: Use handoff_key for scenarios (#13755)
# Pull Request Template
## Description
Ensure agent function names stay within OpenAI's 64-char limit
(ai-agents prepends "handoff_to_").
Add HANDOFF_TITLE_SLUG_MAX_LENGTH and
handoff_key generation: persisted records use `scenario_{id}_agent`; new
records use a truncated title slug.
Assistant scenario keys and agent_name now reference the generated
handoff key.
fixes :
`Invalid 'messages[9].tool_calls[0].function.name': string too long.
Expected a string with maximum length 64, but got a string with length
95 instead.`
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
Tested locally
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Shivam Mishra
---
enterprise/app/models/captain/assistant.rb | 2 +-
enterprise/app/models/captain/scenario.rb | 40 ++++++++++++++++++-
.../models/captain/scenario_spec.rb | 39 ++++++++++++++++++
3 files changed, 79 insertions(+), 2 deletions(-)
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/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) }
From 87f5af4caa2fd9506c8f22e5dfcee0bf310b0872 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Wed, 11 Mar 2026 14:05:16 +0530
Subject: [PATCH 3/3] fix: playground captain v2 scenarios (#13747)
# Pull Request Template
## Description
Playground now uses v2. It was only wired to use v1. Traces get `source:
playground` on langfuse when playground has been used.
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally and specs
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
---
.../captain/assistant/AssistantPlayground.vue | 17 +++--
.../accounts/captain/assistants_controller.rb | 39 ++++++++--
enterprise/app/helpers/captain/chat_helper.rb | 7 +-
.../captain/assistant/agent_runner_service.rb | 12 ++--
.../captain/llm/assistant_chat_service.rb | 3 +-
.../captain/assistants_controller_spec.rb | 72 ++++++++++++++++---
6 files changed, 120 insertions(+), 30 deletions(-)
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