fix(captain): respect channel message limits (#14982)

Captain now keeps v2 replies within each conversation channel's delivery
limit, preventing generated responses from being rejected by providers
such as Instagram, Facebook, and WhatsApp.

## Closes

-
https://linear.app/chatwoot/issue/AI-188/captain-should-respect-whatsapp-character-limits

## How to reproduce

1. Enable Captain v2 on an Instagram inbox.
2. Ask a question that produces a response longer than 1,000 characters.
3. Observe that Meta rejects the outgoing message with error 100 because
it exceeds Instagram's character limit.

## What changed

- Resolve the outbound character limit from the conversation channel,
including provider-specific Twilio limits.
- Add the resolved limit to the assistant and scenario prompts.
- Apply the same limit to the v2 structured response schema so the model
output conforms before delivery.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
This commit is contained in:
Aakash Bakhle
2026-07-29 17:56:40 +05:30
committed by GitHub
parent 651db39765
commit 4ac2432b77
8 changed files with 144 additions and 3 deletions

View File

@@ -24,7 +24,8 @@ module Concerns::Agentable
current_time: format_current_time(state[:timezone]),
conversation: state[:conversation] || {},
contact: config['feature_contact_attributes'].present? ? state[:contact] : nil,
campaign: state[:campaign] || {}
campaign: state[:campaign] || {},
message_length_limit: state[:message_length_limit]
)
end

View File

@@ -20,6 +20,10 @@ class Captain::Assistant::AgentRunnerService
def generate_response(message_history: [])
message_to_process, context = run_payload(message_history)
@last_run_result = runner.run(message_to_process, context: context, max_turns: 10)
record_turn_start(@last_run_result)
@last_run_result = rewrite_oversized_response(@last_run_result) if response_too_long?(@last_run_result)
raise "Captain response exceeds the channel limit of #{message_length_limit} characters" if response_too_long?(@last_run_result)
process_agent_result(@last_run_result)
rescue StandardError => e
@@ -93,6 +97,35 @@ class Captain::Assistant::AgentRunnerService
response
end
def rewrite_oversized_response(result)
response_rewriter.rewrite(result, response: response_text(result), limit: message_length_limit)
end
def response_rewriter
@response_rewriter ||= Captain::Assistant::ResponseRewriter.new(
assistant: @assistant,
attribute_provider: Captain::Assistant::InstrumentationAttributeProvider.new(self)
)
end
def record_turn_start(result)
history = Array(result.context&.dig(:conversation_history))
turn_start_index = history.rindex { |message| message[:role].to_s == 'user' }
result.context[:captain_v2_turn_start_index] = turn_start_index if turn_start_index
end
def response_too_long?(result)
message_length_limit && response_text(result).length > message_length_limit
end
def response_text(result)
extract_text_from_content(result.output).to_s
end
def message_length_limit
@message_length_limit ||= Captain::MessageLengthLimit.for(@conversation)
end
def error_response(error_message)
{
'response' => 'conversation_handoff',

View File

@@ -0,0 +1,69 @@
require 'agents'
require 'agents/instrumentation'
class Captain::Assistant::ResponseRewriter
include Integrations::LlmInstrumentationConstants
AGENT_NAME = 'captain_response_rewriter'.freeze
INSTRUCTIONS = 'Shorten customer support responses without changing their meaning or adding information.'.freeze
def initialize(assistant:, attribute_provider:)
@assistant = assistant
@attribute_provider = attribute_provider
end
def rewrite(result, response:, limit:)
prompt = "The response below is #{response.length} characters, but this channel allows a maximum of #{limit}. " \
'Shorten it while preserving names, numbers, dates, links, warnings, and completed actions. ' \
"Do not add facts.\n\nResponse:\n#{response}"
context = {
session_id: result.context[:session_id],
state: result.context[:state],
captain_v2_trace_input: prompt
}
rewritten_result = runner.run(prompt, context: context, max_turns: 1)
raise rewritten_result.error || 'Captain response rewrite failed' if rewritten_result.failed?
replace_final_assistant_output(result.context[:conversation_history], rewritten_result.output)
replace_final_assistant_output(result.messages, rewritten_result.output)
result.output = rewritten_result.output
result
end
private
def runner
@runner ||= begin
agent = Agents::Agent.new(
name: AGENT_NAME,
instructions: INSTRUCTIONS,
model: @assistant.agent_model,
temperature: 0,
response_schema: Captain::ResponseSchema
)
Agents::Runner.with_agents(agent).tap { |runner| install_instrumentation(runner) }
end
end
def install_instrumentation(runner)
return unless ChatwootApp.otel_enabled?
Agents::Instrumentation.install(
runner,
tracer: OpentelemetryConfig.tracer,
trace_name: 'llm.captain_v2.rewrite',
span_attributes: {
ATTR_LANGFUSE_TAGS => %w[captain_v2 channel_limit_rewrite].to_json,
format(ATTR_LANGFUSE_METADATA, 'credit_used') => 'false'
},
attribute_provider: @attribute_provider
)
end
def replace_final_assistant_output(messages, output)
index = Array(messages).rindex { |message| message[:role].to_s == 'assistant' }
return unless index
messages[index] = messages[index].merge(content: output)
end
end

View File

@@ -31,6 +31,7 @@ module Captain::Assistant::RunnerStateHelper
def build_conversation_state(state)
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
state[:channel_type] = @conversation.inbox&.channel_type
state[:message_length_limit] = Captain::MessageLengthLimit.for(@conversation)
state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact
state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox

View File

@@ -74,8 +74,8 @@ class Captain::Assistant::SessionCaptureService
# (assistant replies, tool calls/results, handoff hops).
def current_turn_history
history = Array(context[:conversation_history])
last_user_index = history.rindex { |message| message[:role].to_s == 'user' }
current_turn = last_user_index ? history[last_user_index..] : history
turn_start_index = context[:captain_v2_turn_start_index] || history.rindex { |message| message[:role].to_s == 'user' } || 0
current_turn = history[turn_start_index..]
current_turn.map do |message|
content = message[:content]

View File

@@ -0,0 +1,27 @@
class Captain::MessageLengthLimit
DEFAULT = 10_000
INSTAGRAM_DIRECT_MESSAGE = 'instagram_direct_message'.freeze
CHANNEL_LIMITS = {
'Channel::FacebookPage' => 2_000,
'Channel::Instagram' => 1_000,
'Channel::Line' => 5_000,
'Channel::Sms' => 320,
'Channel::Telegram' => 4_096,
'Channel::Tiktok' => 6_000,
'Channel::Whatsapp' => 4_096
}.freeze
TWILIO_LIMITS = {
'sms' => 320,
'whatsapp' => 1_600
}.freeze
def self.for(conversation)
return unless conversation
inbox = conversation.inbox
return CHANNEL_LIMITS.fetch('Channel::Instagram') if conversation.additional_attributes['type'] == INSTAGRAM_DIRECT_MESSAGE
return TWILIO_LIMITS.fetch(inbox.channel.medium) if inbox.twilio?
CHANNEL_LIMITS.fetch(inbox.channel_type, DEFAULT)
end
end

View File

@@ -50,6 +50,11 @@ Always respect these boundaries:
When a Response Guideline or Guardrail explicitly requires transfer for a matched condition, follow it instead of the generic consent-first handoff defaults below.
{% if message_length_limit -%}
# Channel Requirements
Keep your response at or under {{ message_length_limit }} characters so it can be delivered through this channel.
{% endif -%}
# Decision Framework
## 1. Analyze the Request

View File

@@ -47,6 +47,11 @@ Always respect these boundaries:
{% endfor %}
{% endif -%}
{% if message_length_limit -%}
# Channel Requirements
Keep your response at or under {{ message_length_limit }} characters so it can be delivered through this channel.
{% endif -%}
{% if tools.size > 0 -%}
# Available Tools
You have access to these tools: