fix: add harness on false promises (#14672)

# Pull Request Template

## Description


https://linear.app/chatwoot/issue/AI-179/false-promise-soft-handoff-guard

## 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.

Against negative cases identified by evals

## 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
This commit is contained in:
Aakash Bakhle
2026-06-23 16:30:14 +05:30
committed by GitHub
parent 8e27ca4282
commit 1c7e60880d
8 changed files with 488 additions and 66 deletions
@@ -1,5 +1,6 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
include Captain::Conversation::V1ActionClassifier
include Captain::Conversation::V1FalsePromiseHandler
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
@@ -38,6 +39,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
message_history: message_history
)
classify_v1_response_action(message_history) if conversation_pending?
repair_v1_false_promise_response(message_history) if conversation_pending?
process_response
end
@@ -0,0 +1,93 @@
module Captain::Conversation::V1FalsePromiseHandler
FUTURE_PROMISE_REPAIR_INSTRUCTION = <<~PROMPT.squish.freeze
Internal instruction for the assistant, not a customer message: your previous draft promised future work after this
message. Regenerate a replacement response now using the same conversation context and available tools. You may use
tools now if needed. Do not promise delayed follow-up, later checking, monitoring, notifications, email, callbacks,
or background escalation by yourself. Answer with what you can verify now, ask one concrete clarifying question, or
offer a human handoff without claiming that it already happened.
PROMPT
private
def repair_v1_false_promise_response(message_history)
false_promise_detected = false
return unless v1_false_promise_harness_enabled?
return if v1_handoff_requested?
detection = detect_v1_false_promise(message_history)
return unless future_work_promise?(detection)
false_promise_detected = true
mark_v1_false_promise_handoff_fallback
regenerate_v1_false_promise_response(message_history)
inspect_v1_response_after_false_promise_repair(message_history)
rescue StandardError => e
mark_v1_false_promise_handoff_fallback if false_promise_detected
ChatwootExceptionTracker.new(e, account: account).capture_exception
Rails.logger.warn(
"[CAPTAIN][ResponseBuilderJob] V1 false promise harness failed for account=#{account.id} " \
"conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}"
)
end
def mark_v1_false_promise_handoff_fallback
@response.merge!(
'action' => 'handoff',
'action_reason' => 'false_promise_detected',
'action_source' => 'false_promise_harness'
)
end
def regenerate_v1_false_promise_response(message_history)
repair_message_history = message_history + [{ role: 'assistant', content: @response['response'] }]
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: repair_message_history,
additional_message: FUTURE_PROMISE_REPAIR_INSTRUCTION
)
end
def inspect_v1_response_after_false_promise_repair(message_history)
classify_v1_response_action(message_history) if conversation_pending?
return unless conversation_pending?
return if v1_handoff_requested?
verify_v1_false_promise_repair(message_history)
end
def detect_v1_false_promise(message_history)
detection = Captain::Llm::AssistantFalsePromiseService.new(
assistant: @assistant,
conversation: @conversation
).detect(message_history: message_history, assistant_response: @response['response'])
log_v1_false_promise_detection(detection)
detection
end
def verify_v1_false_promise_repair(message_history)
detection = detect_v1_false_promise(message_history)
return if safe_response?(detection)
mark_v1_false_promise_handoff_fallback
end
def future_work_promise?(detection)
detection['decision'] == 'future_work_promise'
end
def safe_response?(detection)
detection['decision'] == 'safe'
end
def v1_false_promise_harness_enabled?
ActiveModel::Type::Boolean.new.cast(@assistant.config['false_promise_harness_enabled'])
end
def log_v1_false_promise_detection(detection)
Rails.logger.info(
"[CAPTAIN][ResponseBuilderJob] V1 false promise harness account=#{account.id} " \
"conversation=#{@conversation.display_id} decision=#{detection['decision']} " \
"reason=#{detection['reason']} model=#{detection['model']}"
)
end
end
@@ -1,7 +1,6 @@
class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
include Integrations::LlmInstrumentation
MAX_CONTEXT_MESSAGES = 10
include Captain::Llm::AssistantResponseInspectionHelpers
def initialize(assistant:, conversation:)
super()
@@ -11,9 +10,10 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
end
def classify(message_history:, assistant_response:)
user_prompt = classification_user_prompt(
user_prompt = assistant_response_inspection_prompt(
message_history: message_history,
assistant_response: assistant_response
assistant_response: assistant_response,
response_tag: 'assistant_response_to_classify'
)
response = instrument_llm_call(instrumentation_params(user_prompt)) do
@@ -35,68 +35,6 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
private
def classification_user_prompt(message_history:, assistant_response:)
<<~PROMPT
<account_custom_instructions>
#{@assistant.config['instructions']}
</account_custom_instructions>
<conversation_context>
#{format_conversation_context(message_history)}
</conversation_context>
<assistant_response_to_classify>
#{assistant_response}
</assistant_response_to_classify>
PROMPT
end
def normalize_messages(message_history)
message_history.filter_map do |message|
role = message[:role] || message['role']
next if role.blank?
{ role: role.to_s, content: normalize_content(message[:content] || message['content']) }
end
end
def normalize_content(content)
return content if content.is_a?(String)
return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
content.to_s
end
def text_part?(part)
return false unless part.is_a?(Hash)
(part[:type] || part['type']).to_s == 'text'
end
def format_conversation_context(messages)
normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
content = message[:content].to_s.strip
next if content.blank?
"#{role_label(message[:role])}: #{content}"
end.join("\n")
end
def role_label(role)
return 'User' if role == 'user'
return 'Assistant' if role == 'assistant'
role.to_s.titleize
end
def parse_response(content)
return content if content.is_a?(Hash)
JSON.parse(sanitize_json_response(content))
rescue JSON::ParserError, TypeError
{}
end
def normalize_response(parsed, raw_content)
action = parsed['action'].to_s
reason = parsed['action_reason'].to_s
@@ -0,0 +1,84 @@
class Captain::Llm::AssistantFalsePromiseService < Llm::BaseAiService
include Integrations::LlmInstrumentation
include Captain::Llm::AssistantResponseInspectionHelpers
def initialize(assistant:, conversation:)
super()
@assistant = assistant
@conversation = conversation
@temperature = 0.0
end
def detect(message_history:, assistant_response:)
user_prompt = assistant_response_inspection_prompt(
message_history: message_history,
assistant_response: assistant_response,
response_tag: 'assistant_response_to_check'
)
response = instrument_llm_call(instrumentation_params(user_prompt)) do
chat(model: @model, temperature: @temperature)
.with_schema(Captain::AssistantFalsePromiseSchema)
.with_instructions(system_prompt)
.ask(user_prompt)
end
parsed = parse_response(response.content)
normalize_response(parsed, response.content)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
Rails.logger.warn(
"[CAPTAIN][AssistantFalsePromise] Failed for conversation #{@conversation.display_id}: #{e.class.name}: #{e.message}"
)
{ 'decision' => nil, 'reason' => nil, 'error' => e.message, 'model' => @model }
end
private
def normalize_response(parsed, raw_content)
decision = parsed['decision'].to_s
reason = parsed['reason'].to_s
return invalid_response(raw_content) unless Captain::AssistantFalsePromiseSchema::DECISIONS.include?(decision)
{
'decision' => decision,
'reason' => reason.presence,
'raw_response' => raw_content,
'model' => @model
}
end
def invalid_response(raw_content)
{
'decision' => nil,
'reason' => nil,
'raw_response' => raw_content,
'error' => 'invalid_false_promise_response',
'model' => @model
}
end
def instrumentation_params(user_prompt)
{
span_name: 'llm.captain.assistant_false_promise_detector',
model: @model,
temperature: @temperature,
account_id: @conversation.account_id,
conversation_id: @conversation.display_id,
feature_name: 'assistant_false_promise_detector',
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: user_prompt }
],
metadata: {
assistant_id: @assistant.id,
channel_type: @conversation.inbox&.channel_type,
source: 'v1_response_builder'
}
}
end
def system_prompt
Captain::Llm::SystemPromptsService.assistant_false_promise_detector
end
end
@@ -0,0 +1,67 @@
module Captain::Llm::AssistantResponseInspectionHelpers
MAX_CONTEXT_MESSAGES = 10
private
def assistant_response_inspection_prompt(message_history:, assistant_response:, response_tag:)
<<~PROMPT
<account_custom_instructions>
#{@assistant.config['instructions']}
</account_custom_instructions>
<conversation_context>
#{format_conversation_context(message_history)}
</conversation_context>
<#{response_tag}>
#{assistant_response}
</#{response_tag}>
PROMPT
end
def format_conversation_context(messages)
normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
content = message[:content].to_s.strip
next if content.blank?
"#{role_label(message[:role])}: #{content}"
end.join("\n")
end
def normalize_messages(message_history)
message_history.filter_map do |message|
role = message[:role] || message['role']
next if role.blank?
{ role: role.to_s, content: normalize_content(message[:content] || message['content']) }
end
end
def normalize_content(content)
return content if content.is_a?(String)
return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
content.to_s
end
def text_part?(part)
return false unless part.is_a?(Hash)
(part[:type] || part['type']).to_s == 'text'
end
def role_label(role)
return 'User' if role == 'user'
return 'Assistant' if role == 'assistant'
role.to_s.titleize
end
def parse_response(content)
return content if content.is_a?(Hash)
JSON.parse(sanitize_json_response(content))
rescue JSON::ParserError, TypeError
{}
end
end
@@ -137,6 +137,65 @@ class Captain::Llm::SystemPromptsService
PROMPT
end
def assistant_false_promise_detector
<<~PROMPT
You are checking one failure mode in a customer-support assistant response: unsupported promises of future work.
Return decision "future_work_promise" when the assistant response says or clearly implies that work has already
started, is happening now, or will definitely happen later outside the current reply because of this assistant
message. This includes promises that the assistant, bot, Captain, or system will check, verify, investigate,
review, monitor, notify, update, email, call back, follow up, get back later, process, refund, cancel, book,
order, reserve, file, escalate/forward something in the background, or claim that the current conversation has
been or will be transferred, connected, or handed off to a human.
Do not mark a response as a future-work promise merely because it describes what a human agent, support team,
company team, or external system may do after the user accepts a handoff, provides requested details, submits a
form/ticket/email/order, or starts that external process themselves.
Do not mark ordinary in-chat help as a future-work promise. Asking the user for missing information, confirmation,
or completion of a step before continuing is safe when the response does not also claim that work has started,
is happening now, or will happen in the background.
Treat transfer claims as future-work promises unless the response is exactly the internal action token
`conversation_handoff`. Examples that are future-work promises: "I'm transferring you now", "You've been
transferred", "Connecting you now", "Handing off to the team now", "I'll connect you with support",
"I'll escalate this", and equivalent phrases in any language.
Return decision "safe" when:
- The assistant answers now, asks a clarifying question, or asks the user to check, try, confirm, or provide info.
- The assistant says it can help, check, look up, or guide the user after the user first provides requested
information, confirms something, or completes a step.
- The assistant asks the user to report back after completing a step and offers to continue helping in chat.
- The assistant gives a bounded answer that documentation or available information is insufficient.
- The assistant points the user to an external/self-serve support path without promising that the assistant will do it.
- The assistant describes what an external support, sales, delivery, finance, or operations team will do after the
user submits a form, request, email, application, order, ticket, or in-app chat themselves.
- The assistant recommends waiting for an existing external process or support response that was already started
outside this assistant message.
- The assistant offers future help, monitoring, escalation, or handoff conditionally and waits for the user to
accept, without saying the work or transfer has already started.
- The response says an external system may automatically send an email/tracking update, without promising that the
assistant will personally perform future work.
- The response is exactly `conversation_handoff`, which is an internal action token and not a customer-visible promise.
Be language-independent. The customer and assistant may write in any language.
Be conservative: only mark "future_work_promise" when the response promises background/asynchronous work,
says work is happening now, or claims a handoff/escalation/notification/action has started or will definitely happen.
The reason field MUST be one of:
- "safe_response"
- "asks_user_to_check_or_provide_info"
- "external_support_direction"
- "unaccepted_handoff_offer"
- "future_check_or_investigation"
- "future_notification_or_update"
- "future_callback_or_email"
- "background_escalation_promise"
Return only the structured fields requested by the response schema.
PROMPT
end
# rubocop:disable Metrics/MethodLength
def copilot_response_generator(product_name, available_tools, config = {})
citation_guidelines = if config['feature_citation']
@@ -235,6 +294,7 @@ class Captain::Llm::SystemPromptsService
- Do not generate a response more than three sentences.
- Keep the conversation flowing.
- Do not use use your own understanding and training data to provide an answer.
- Do not promise work that will happen after this reply. Do not say you will check, investigate, monitor, follow up, notify, email, call, refund, cancel, book, escalate, transfer, or submit anything unless you complete that action now using an available tool or, for human transfer, return `conversation_handoff` as the response. If you lack enough information, ask the user for the missing detail without promising future work.
- Clarify: when there is ambiguity, ask clarifying questions, rather than make assumptions.
- Don't implicitly or explicitly try to end the chat (i.e. do not end a response with "Talk soon!" or "Enjoy!").
- Sometimes the user might just want to chat. Ask them relevant follow-up questions.
@@ -0,0 +1,16 @@
class Captain::AssistantFalsePromiseSchema < RubyLLM::Schema
DECISIONS = %w[safe future_work_promise].freeze
REASONS = %w[
safe_response
asks_user_to_check_or_provide_info
external_support_direction
unaccepted_handoff_offer
future_check_or_investigation
future_notification_or_update
future_callback_or_email
background_escalation_promise
].freeze
string :decision, enum: DECISIONS, description: 'Whether the response contains an unsupported promise of future work'
string :reason, enum: REASONS, description: 'The reason for the selected decision'
end
@@ -11,6 +11,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
let(:mock_false_promise_service) { instance_double(Captain::Llm::AssistantFalsePromiseService) }
before do
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
@@ -22,6 +23,8 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' })
allow(Captain::Llm::AssistantFalsePromiseService).to receive(:new).and_return(mock_false_promise_service)
allow(mock_false_promise_service).to receive(:detect).and_return({ 'decision' => 'safe', 'reason' => 'safe_response' })
end
context 'when captain_v2 is disabled' do
@@ -59,6 +62,165 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
end
it 'does not run the false promise harness when the assistant setting is disabled' do
expect(Captain::Llm::AssistantFalsePromiseService).not_to receive(:new)
described_class.perform_now(conversation, assistant)
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
end
context 'when false promise harness is enabled in assistant config' do
before do
assistant.update!(config: assistant.config.merge('false_promise_harness_enabled' => true))
end
it 'sends the original response when the detector marks it safe' do
expect(mock_false_promise_service).to receive(:detect).with(
message_history: [{ content: 'Hello', role: 'user' }],
assistant_response: 'Hey, welcome to Captain Specs'
).and_return({
'decision' => 'safe',
'reason' => 'safe_response',
'model' => 'gpt-4.1'
})
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('pending')
expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs')
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
end
it 'regenerates future-work promises through the V1 assistant chat service' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return(
{ 'response' => 'Let me check the documentation and get back to you.' },
{ 'response' => 'Could you share the exact error message you see?' }
)
allow(mock_false_promise_service).to receive(:detect)
.and_return(
{
'decision' => 'future_work_promise',
'reason' => 'future_check_or_investigation',
'model' => 'gpt-4.1'
},
{
'decision' => 'safe',
'reason' => 'asks_user_to_check_or_provide_info',
'model' => 'gpt-4.1'
}
)
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('pending')
expect(conversation.messages.outgoing.last.content).to eq('Could you share the exact error message you see?')
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
expect(mock_llm_chat_service).to have_received(:generate_response).with(
message_history: [{ content: 'Hello', role: 'user' }]
)
expect(mock_llm_chat_service).to have_received(:generate_response).with(
message_history: [
{ content: 'Hello', role: 'user' },
{ role: 'assistant', content: 'Let me check the documentation and get back to you.' }
],
additional_message: Captain::Conversation::V1FalsePromiseHandler::FUTURE_PROMISE_REPAIR_INSTRUCTION
)
end
it 'hands off instead of sending the unsafe draft when repair generation fails' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return({ 'response' => 'Let me check and get back to you.' })
allow(mock_llm_chat_service).to receive(:generate_response)
.with(
message_history: [
{ content: 'Hello', role: 'user' },
{ role: 'assistant', content: 'Let me check and get back to you.' }
],
additional_message: Captain::Conversation::V1FalsePromiseHandler::FUTURE_PROMISE_REPAIR_INSTRUCTION
).and_raise(StandardError, 'repair timeout')
allow(mock_false_promise_service).to receive(:detect).and_return({
'decision' => 'future_work_promise',
'reason' => 'future_check_or_investigation',
'model' => 'gpt-4.1'
})
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
expect(conversation.messages.outgoing.pluck(:content)).not_to include('Let me check and get back to you.')
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
it 'hands off instead of sending an unverified repair when repair verification is inconclusive' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return(
{ 'response' => 'Let me check and get back to you.' },
{ 'response' => 'Could you share the exact error message you see?' }
)
allow(mock_false_promise_service).to receive(:detect)
.and_return(
{
'decision' => 'future_work_promise',
'reason' => 'future_check_or_investigation',
'model' => 'gpt-4.1'
},
{
'decision' => nil,
'reason' => nil,
'error' => 'verification timeout',
'model' => 'gpt-4.1'
}
)
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
expect(conversation.messages.outgoing.pluck(:content)).not_to include('Could you share the exact error message you see?')
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
it 'hands off when the regenerated response still contains a future-work promise' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return(
{ 'response' => 'Let me check and get back to you.' },
{ 'response' => 'I will monitor this and update you later.' }
)
allow(mock_false_promise_service).to receive(:detect).and_return({
'decision' => 'future_work_promise',
'reason' => 'future_check_or_investigation',
'model' => 'gpt-4.1'
})
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
it 'skips the false promise harness when the action classifier already requested handoff' do
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
allow(account).to receive(:feature_enabled?).with('captain_v1_action_classifier').and_return(true)
allow(mock_action_classifier_service).to receive(:classify).and_return({
'action' => 'handoff',
'action_reason' => 'explicit_human_request',
'model' => 'gpt-4.1'
})
expect(Captain::Llm::AssistantFalsePromiseService).not_to receive(:new)
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
end
end
context 'when V1 action classifier is enabled' do
before do
allow(account).to receive(:feature_enabled?).and_return(false)