feat: add campaign context to Captain v2 prompts (#13644)

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
This commit is contained in:
Shivam Mishra
2026-02-25 18:33:37 +05:30
committed by GitHub
parent ba804e0f30
commit b98c614669
7 changed files with 83 additions and 5 deletions

View File

@@ -19,9 +19,11 @@ module Concerns::Agentable
state = context.context[:state] || {}
conversation_data = state[:conversation] || {}
contact_data = state[:contact] || {}
campaign_data = state[:campaign] || {}
enhanced_context = enhanced_context.merge(
conversation: conversation_data,
contact: contact_data
contact: contact_data,
campaign: campaign_data
)
end

View File

@@ -16,6 +16,8 @@ class Captain::Assistant::AgentRunnerService
custom_attributes additional_attributes
].freeze
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
def initialize(assistant:, conversation: nil, callbacks: {})
@assistant = assistant
@conversation = conversation
@@ -129,6 +131,7 @@ class Captain::Assistant::AgentRunnerService
state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES)
state[:channel_type] = @conversation.inbox&.channel_type
state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
end
state

View File

@@ -8,7 +8,7 @@ You are {{name}}, a helpful and knowledgeable assistant. Your role is to primari
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this.
{% if conversation || contact -%}
{% if conversation || contact || campaign.id -%}
# Current Context
Here's the metadata we have about the current conversation and the contact associated with it:
@@ -20,6 +20,10 @@ Here's the metadata we have about the current conversation and the contact assoc
{% if contact -%}
{% render 'contact' %}
{% endif -%}
{% if campaign.id -%}
{% render 'campaign' %}
{% endif -%}
{% endif -%}
{% if response_guidelines.size > 0 -%}

View File

@@ -8,7 +8,7 @@ You are a specialized agent called "{{ title }}", your task is to handle the fol
If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `handoff_to_{{ assistant_name }}` tool
{% if conversation || contact %}
{% if conversation || contact || campaign.id %}
# Current Context
Here's the metadata we have about the current conversation and the contact associated with it:
@@ -20,6 +20,10 @@ Here's the metadata we have about the current conversation and the contact assoc
{% if contact -%}
{% render 'contact' %}
{% endif -%}
{% if campaign.id -%}
{% render 'campaign' %}
{% endif -%}
{% endif -%}

View File

@@ -0,0 +1,8 @@
# Campaign Context
This conversation was initiated in response to a campaign message.
- Campaign: {{ campaign.title }}
- Type: {{ campaign.campaign_type }}
{% if campaign.description -%}
- Description: {{ campaign.description }}
{% endif -%}
- Original Message Sent: {{ campaign.message }}

View File

@@ -97,7 +97,8 @@ RSpec.describe Concerns::Agentable do
expected_context = {
base_key: 'base_value',
conversation: { id: 123 },
contact: { name: 'John' }
contact: { name: 'John' },
campaign: {}
}
expect(Captain::PromptRenderer).to receive(:render).with(
@@ -108,6 +109,26 @@ RSpec.describe Concerns::Agentable do
dummy_instance.agent_instructions(context_double)
end
it 'merges campaign data from context state' do
context_double = instance_double(Agents::RunContext,
context: {
state: {
conversation: { id: 123 },
contact: { name: 'John' },
campaign: { id: 10, title: 'Summer Sale', message: 'Check it out' }
}
})
expect(Captain::PromptRenderer).to receive(:render).with(
'dummy_class',
hash_including(
campaign: { id: 10, title: 'Summer Sale', message: 'Check it out' }
)
)
dummy_instance.agent_instructions(context_double)
end
it 'handles context without state' do
context_double = instance_double(Agents::RunContext, context: {})
@@ -116,7 +137,8 @@ RSpec.describe Concerns::Agentable do
hash_including(
base_key: 'base_value',
conversation: {},
contact: {}
contact: {},
campaign: {}
)
)

View File

@@ -394,6 +394,34 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
)
end
it 'does not include campaign when conversation has no campaign' do
state = service.send(:build_state)
expect(state).not_to have_key(:campaign)
end
context 'when conversation has a campaign' do
let(:campaign) { create(:campaign, account: account, title: 'Summer Sale', message: 'Check out our deals!', description: 'Seasonal promo') }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, campaign: campaign) }
it 'includes campaign attributes in state' do
state = service.send(:build_state)
expect(state[:campaign]).to include(
id: campaign.id,
title: 'Summer Sale',
message: 'Check out our deals!',
description: 'Seasonal promo'
)
end
it 'only includes attributes defined in CAMPAIGN_STATE_ATTRIBUTES' do
state = service.send(:build_state)
expect(state[:campaign].keys).to match_array(described_class::CAMPAIGN_STATE_ATTRIBUTES)
end
end
context 'when conversation is nil' do
subject(:service) { described_class.new(assistant: assistant, conversation: nil) }
@@ -407,6 +435,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
)
expect(state).not_to have_key(:conversation)
expect(state).not_to have_key(:contact)
expect(state).not_to have_key(:campaign)
end
end
end
@@ -477,5 +506,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
:id, :name, :email, :phone_number, :identifier, :contact_type
)
end
it 'defines campaign state attributes' do
expect(described_class::CAMPAIGN_STATE_ATTRIBUTES).to include(
:id, :title, :message, :campaign_type, :description
)
end
end
end