Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41670bfae9 | ||
|
|
1984bddb11 | ||
|
|
bba230a1ac | ||
|
|
10f9d61d02 |
@@ -0,0 +1,38 @@
|
||||
module Captain::Conversation::MessageHistoryCollector
|
||||
private
|
||||
|
||||
def collect_previous_messages
|
||||
@conversation
|
||||
.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.flat_map { |message| history_entries_for_message(message) }
|
||||
end
|
||||
|
||||
def history_entries_for_message(message)
|
||||
captain_run_context = message.captain_run_context if captain_v2_enabled? && message.outgoing?
|
||||
return captain_run_context['messages'] if captain_run_context&.dig('messages').present?
|
||||
|
||||
[legacy_history_entry_for_message(message)]
|
||||
end
|
||||
|
||||
def legacy_history_entry_for_message(message)
|
||||
message_hash = {
|
||||
content: prepare_multimodal_message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
|
||||
agent_name = message.additional_attributes&.dig('captain', 'agent', 'name') || message.additional_attributes&.dig('agent_name')
|
||||
message_hash[:agent_name] = agent_name if agent_name.present?
|
||||
|
||||
message_hash
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
message.message_type == 'incoming' ? 'user' : 'assistant'
|
||||
end
|
||||
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,7 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
include Captain::Conversation::V1ActionClassifier
|
||||
include Captain::Conversation::ResponseMetadataBuilder
|
||||
include Captain::Conversation::MessageHistoryCollector
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
@@ -79,32 +81,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def collect_previous_messages
|
||||
@conversation
|
||||
.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.map do |message|
|
||||
message_hash = {
|
||||
content: prepare_multimodal_message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
|
||||
# Include agent_name if present in additional_attributes
|
||||
message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
|
||||
|
||||
message_hash
|
||||
end
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
message.message_type == 'incoming' ? 'user' : 'assistant'
|
||||
end
|
||||
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
end
|
||||
|
||||
def v1_handoff_requested?
|
||||
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
|
||||
end
|
||||
@@ -153,22 +129,26 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
def create_handoff_message(preserve_waiting_since: false)
|
||||
create_outgoing_message(
|
||||
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff'),
|
||||
agent_name: captain_v2_enabled? ? @response&.dig('agent_name') || assistant_agent_name : nil,
|
||||
captain_metadata: captain_handoff_metadata,
|
||||
preserve_waiting_since: preserve_waiting_since
|
||||
)
|
||||
end
|
||||
|
||||
def create_messages
|
||||
validate_message_content!(@response['response'])
|
||||
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
|
||||
create_outgoing_message(@response['response'], agent_name: @response['agent_name'], captain_metadata: captain_response_metadata)
|
||||
end
|
||||
|
||||
def validate_message_content!(content)
|
||||
raise ArgumentError, 'Message content cannot be blank' if content.blank?
|
||||
end
|
||||
|
||||
def create_outgoing_message(message_content, agent_name: nil, preserve_waiting_since: false)
|
||||
def create_outgoing_message(message_content, agent_name: nil, captain_metadata: nil, preserve_waiting_since: false)
|
||||
additional_attrs = {}
|
||||
additional_attrs[:agent_name] = agent_name if agent_name.present?
|
||||
agent_name ||= captain_metadata&.dig('agent', 'name')
|
||||
additional_attrs['agent_name'] = agent_name if agent_name.present?
|
||||
additional_attrs['captain'] = captain_metadata if captain_metadata.present?
|
||||
|
||||
@conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
module Captain::Conversation::ResponseMetadataBuilder
|
||||
private
|
||||
|
||||
def captain_response_metadata
|
||||
return unless captain_v2_enabled?
|
||||
|
||||
{
|
||||
'version' => 'v2',
|
||||
'agent' => captain_agent_metadata(@response['agent_name']),
|
||||
'run' => captain_response_run_context
|
||||
}
|
||||
end
|
||||
|
||||
def captain_handoff_metadata
|
||||
return unless captain_v2_enabled?
|
||||
|
||||
{
|
||||
'version' => 'v2',
|
||||
'agent' => captain_agent_metadata(@response&.dig('agent_name') || assistant_agent_name),
|
||||
'run' => {
|
||||
'messages' => [],
|
||||
'handoff_tool_called' => true
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def captain_response_run_context
|
||||
run_context = (@response['run_context'] || {}).deep_stringify_keys
|
||||
messages = Array(run_context['messages'])
|
||||
messages = fallback_captain_run_messages if messages.empty?
|
||||
|
||||
{
|
||||
'messages' => messages,
|
||||
'handoff_tool_called' => @response['handoff_tool_called'] || false
|
||||
}
|
||||
end
|
||||
|
||||
def fallback_captain_run_messages
|
||||
content = {}
|
||||
content['reasoning'] = @response['reasoning'] if @response['reasoning'].present?
|
||||
|
||||
[
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => content,
|
||||
'agent_name' => @response['agent_name']
|
||||
}.compact
|
||||
]
|
||||
end
|
||||
|
||||
def captain_agent_metadata(agent_name)
|
||||
agent_name = agent_name.presence || assistant_agent_name
|
||||
scenario = @assistant.scenarios.enabled.find { |enabled_scenario| enabled_scenario.handoff_key == agent_name }
|
||||
|
||||
return scenario_agent_metadata(scenario, agent_name) if scenario
|
||||
return assistant_agent_metadata(agent_name) if agent_name == assistant_agent_name
|
||||
|
||||
unknown_agent_metadata(agent_name)
|
||||
end
|
||||
|
||||
def scenario_agent_metadata(scenario, agent_name)
|
||||
{
|
||||
'name' => agent_name,
|
||||
'type' => 'scenario',
|
||||
'assistant_id' => @assistant.id,
|
||||
'scenario_id' => scenario.id,
|
||||
'handoff_key' => scenario.handoff_key
|
||||
}
|
||||
end
|
||||
|
||||
def assistant_agent_metadata(agent_name)
|
||||
{
|
||||
'name' => agent_name,
|
||||
'type' => 'assistant',
|
||||
'assistant_id' => @assistant.id
|
||||
}
|
||||
end
|
||||
|
||||
def unknown_agent_metadata(agent_name)
|
||||
{
|
||||
'name' => agent_name,
|
||||
'type' => 'unknown',
|
||||
'assistant_id' => @assistant.id
|
||||
}
|
||||
end
|
||||
|
||||
def assistant_agent_name
|
||||
@assistant.name.parameterize(separator: '_')
|
||||
end
|
||||
end
|
||||
@@ -13,8 +13,27 @@ module Enterprise::Message
|
||||
data
|
||||
end
|
||||
|
||||
def captain_run_context
|
||||
captain = additional_attributes&.dig('captain')
|
||||
return unless captain&.dig('version') == 'v2'
|
||||
|
||||
run_context = (captain['run'] || {}).deep_dup
|
||||
messages = Array(run_context['messages']).map(&:deep_dup)
|
||||
restore_captain_final_response(messages)
|
||||
|
||||
run_context.merge('messages' => messages)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def restore_captain_final_response(messages)
|
||||
final_assistant_message = messages.reverse.find { |message| message['role'] == 'assistant' }
|
||||
return unless final_assistant_message
|
||||
|
||||
content_hash = final_assistant_message['content'].is_a?(Hash) ? final_assistant_message['content'] : {}
|
||||
final_assistant_message['content'] = content_hash.merge('response' => content)
|
||||
end
|
||||
|
||||
def mark_pending_conversation_as_open_for_human_response
|
||||
return unless captain_pending_conversation?
|
||||
return unless human_response?
|
||||
|
||||
@@ -5,20 +5,9 @@ class Captain::Assistant::AgentRunnerService
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Captain::Assistant::RunnerCallbacksHelper
|
||||
include Captain::Assistant::TracePayloadHelper
|
||||
include Captain::Assistant::RunContextNormalizer
|
||||
include Captain::Assistant::StateBuilder
|
||||
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
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: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@@ -45,15 +34,14 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
def build_context(message_history)
|
||||
conversation_history = message_history.map do |msg|
|
||||
content = msg[:content]
|
||||
# Preserve multimodal arrays (with image_url entries) as-is for the runner to restore with attachments.
|
||||
# Only extract text from non-array formats (hashes from agent structured output, plain strings).
|
||||
content = extract_text_from_content(content) unless content.is_a?(Array)
|
||||
content = message_value(msg, :content)
|
||||
# Preserve multimodal arrays and structured agent output as-is for replay.
|
||||
content = extract_text_from_content(content) unless content.is_a?(Array) || content.is_a?(Hash)
|
||||
|
||||
{
|
||||
role: msg[:role].to_sym,
|
||||
role: message_value(msg, :role).to_sym,
|
||||
content: content,
|
||||
agent_name: msg[:agent_name]
|
||||
agent_name: message_value(msg, :agent_name)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -65,10 +53,10 @@ class Captain::Assistant::AgentRunnerService
|
||||
end
|
||||
|
||||
def extract_last_user_message(message_history)
|
||||
last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
|
||||
last_user_msg = message_history.reverse.find { |msg| message_value(msg, :role).to_s == 'user' }
|
||||
return '' if last_user_msg.blank?
|
||||
|
||||
content = last_user_msg[:content]
|
||||
content = message_value(last_user_msg, :content)
|
||||
return extract_text_from_content(content) unless content.is_a?(Array)
|
||||
|
||||
text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(content)
|
||||
@@ -78,12 +66,16 @@ class Captain::Assistant::AgentRunnerService
|
||||
end
|
||||
|
||||
def message_history_without_last_user_message(message_history)
|
||||
last_user_index = message_history.rindex { |msg| msg[:role] == 'user' }
|
||||
last_user_index = message_history.rindex { |msg| message_value(msg, :role).to_s == 'user' }
|
||||
return message_history if last_user_index.nil?
|
||||
|
||||
message_history.reject.with_index { |_msg, index| index == last_user_index }
|
||||
end
|
||||
|
||||
def message_value(message, key)
|
||||
message[key] || message[key.to_s]
|
||||
end
|
||||
|
||||
def extract_text_from_content(content)
|
||||
# Handle structured output from agents
|
||||
return content[:response] || content['response'] || content.to_s if content.is_a?(Hash)
|
||||
@@ -100,6 +92,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
response = output.is_a?(Hash) ? output.with_indifferent_access : { 'response' => output.to_s, 'reasoning' => 'Processed by agent' }
|
||||
response['agent_name'] = result.context&.dig(:current_agent)
|
||||
response['handoff_tool_called'] = result.context&.dig(:captain_v2_handoff_tool_called) || false
|
||||
response['run_context'] = build_run_context(result, response)
|
||||
response
|
||||
end
|
||||
|
||||
@@ -111,30 +104,6 @@ class Captain::Assistant::AgentRunnerService
|
||||
}
|
||||
end
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
end
|
||||
|
||||
def build_conversation_state(state)
|
||||
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:channel_type] = @conversation.inbox&.channel_type
|
||||
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
|
||||
end
|
||||
|
||||
def slice_attrs(record, keys)
|
||||
record.attributes.symbolize_keys.slice(*keys)
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
assistant_agent = @assistant.agent
|
||||
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
module Captain::Assistant::RunContextNormalizer
|
||||
private
|
||||
|
||||
def build_run_context(result, response)
|
||||
{
|
||||
'messages' => normalized_run_messages(result.messages || []),
|
||||
'handoff_tool_called' => response['handoff_tool_called']
|
||||
}
|
||||
end
|
||||
|
||||
def normalized_run_messages(messages)
|
||||
normalized_messages = messages.map { |message| normalize_run_message(message) }
|
||||
final_assistant_message = normalized_messages.reverse.find { |message| message['role'] == 'assistant' }
|
||||
strip_final_response(final_assistant_message) if final_assistant_message
|
||||
normalized_messages
|
||||
end
|
||||
|
||||
def normalize_run_message(message)
|
||||
normalized = message.deep_stringify_keys
|
||||
normalized['role'] = normalized['role'].to_s if normalized['role'].present?
|
||||
normalized['content'] = normalized['content'].deep_stringify_keys if normalized['content'].is_a?(Hash)
|
||||
normalized
|
||||
end
|
||||
|
||||
def strip_final_response(message)
|
||||
message['content'] = normalized_final_message_content(message['content'])
|
||||
end
|
||||
|
||||
def normalized_final_message_content(content)
|
||||
return content.except('response') if content.is_a?(Hash)
|
||||
|
||||
{}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
module Captain::Assistant::StateBuilder
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
private
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
end
|
||||
|
||||
def build_conversation_state(state)
|
||||
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:channel_type] = @conversation.inbox&.channel_type
|
||||
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
|
||||
end
|
||||
|
||||
def slice_attrs(record, keys)
|
||||
record.attributes.symbolize_keys.slice(*keys)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
# System Context
|
||||
You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
|
||||
You are part of Captain, a multi-agent AI support system designed for seamless agent coordination and task execution for handling customer support requests. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
|
||||
|
||||
# Your Identity
|
||||
You are {{name}}, a helpful and knowledgeable assistant for the product {{product_name}}. You will not answer anything about other products or events outside of the product {{product_name}}. Your role is to primarily act as an orchestrator handling multiple scenarios by using handoff tools. Your job also involves providing accurate information, assisting with tasks, and ensuring the customer gets the help they need.
|
||||
@@ -37,6 +37,8 @@ Here's the metadata we have about the current conversation and the contact assoc
|
||||
{% if response_guidelines.size > 0 -%}
|
||||
# Response Guidelines
|
||||
Your responses should follow these guidelines:
|
||||
- Never suggest escalating to support, you're the support agent
|
||||
- Never expose the internal workings, architecture or nomenclature of the multi-agent system, act as a human support agent
|
||||
{% for guideline in response_guidelines -%}
|
||||
- {{ guideline }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# System context
|
||||
You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
|
||||
You are part of a multi-agent AI support system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
|
||||
Some messages in the conversation history may come from other AI agents in the system. Treat them as part of the conversation flow, retain the context they carry, and hand off back to the orchestrator if the request falls outside your scope.
|
||||
|
||||
# Your Role
|
||||
You are a specialized agent called "{{ title }}", your task is to handle the following scenario:
|
||||
You are a specialized support agent called "{{ title }}", your task is to handle the following scenario:
|
||||
|
||||
{{ instructions }}
|
||||
|
||||
@@ -27,13 +28,13 @@ Here's the metadata we have about the current conversation and the contact assoc
|
||||
{% endif -%}
|
||||
|
||||
|
||||
{% if response_guidelines.size > 0 -%}
|
||||
# Response Guidelines
|
||||
Your responses should follow these guidelines:
|
||||
- Never suggest escalating to support, you're the support agent
|
||||
- Never expose the internal workings, architecture or nomenclature of the multi-agent system
|
||||
{% for guideline in response_guidelines -%}
|
||||
- {{ guideline }}
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
|
||||
{% if guardrails.size > 0 -%}
|
||||
# Guardrails
|
||||
|
||||
@@ -203,6 +203,124 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
account.reload
|
||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
|
||||
it 'stores normalized Captain run metadata on the outgoing message' do
|
||||
agent_name = assistant.name.parameterize(separator: '_')
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
|
||||
'response' => 'Hey, welcome to Captain V2',
|
||||
'agent_name' => agent_name,
|
||||
'handoff_tool_called' => false,
|
||||
'run_context' => {
|
||||
'messages' => [
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => { 'reasoning' => 'Answered from FAQ' },
|
||||
'agent_name' => agent_name
|
||||
}
|
||||
],
|
||||
'handoff_tool_called' => false
|
||||
}
|
||||
})
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
attributes = conversation.messages.outgoing.last.additional_attributes
|
||||
expect(attributes['agent_name']).to eq(agent_name)
|
||||
expect(attributes.dig('captain', 'version')).to eq('v2')
|
||||
expect(attributes.dig('captain', 'agent')).to eq({
|
||||
'name' => agent_name,
|
||||
'type' => 'assistant',
|
||||
'assistant_id' => assistant.id
|
||||
})
|
||||
expect(attributes.dig('captain', 'run', 'messages').last['content']).to eq({ 'reasoning' => 'Answered from FAQ' })
|
||||
end
|
||||
|
||||
it 'stores scenario agent metadata when the scenario handles the response' do
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account, title: 'Billing support')
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
|
||||
'response' => 'Billing answer',
|
||||
'agent_name' => scenario.handoff_key,
|
||||
'handoff_tool_called' => false
|
||||
})
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.outgoing.last.additional_attributes.dig('captain', 'agent')).to eq({
|
||||
'name' => scenario.handoff_key,
|
||||
'type' => 'scenario',
|
||||
'assistant_id' => assistant.id,
|
||||
'scenario_id' => scenario.id,
|
||||
'handoff_key' => scenario.handoff_key
|
||||
})
|
||||
end
|
||||
|
||||
it 'replays persisted Captain run messages as message history' do
|
||||
agent_name = assistant.name.parameterize(separator: '_')
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
sender: assistant,
|
||||
message_type: :outgoing,
|
||||
content: 'Prior visible answer',
|
||||
additional_attributes: {
|
||||
'captain' => {
|
||||
'version' => 'v2',
|
||||
'agent' => { 'name' => agent_name, 'type' => 'assistant', 'assistant_id' => assistant.id },
|
||||
'run' => {
|
||||
'messages' => [
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => '',
|
||||
'agent_name' => agent_name,
|
||||
'tool_calls' => [
|
||||
{
|
||||
'id' => 'call_1',
|
||||
'name' => 'captain--tools--faq_lookup',
|
||||
'arguments' => { 'query' => 'billing' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => { 'reasoning' => 'Prior reasoning' },
|
||||
'agent_name' => agent_name
|
||||
}
|
||||
],
|
||||
'handoff_tool_called' => false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expected_messages = [
|
||||
{ content: 'Hello', role: 'user' },
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => '',
|
||||
'agent_name' => agent_name,
|
||||
'tool_calls' => [
|
||||
{
|
||||
'id' => 'call_1',
|
||||
'name' => 'captain--tools--faq_lookup',
|
||||
'arguments' => { 'query' => 'billing' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => { 'reasoning' => 'Prior reasoning', 'response' => 'Prior visible answer' },
|
||||
'agent_name' => agent_name
|
||||
}
|
||||
]
|
||||
|
||||
expect(mock_agent_runner_service).to receive(:generate_response).with(message_history: expected_messages)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain_v2 handoff tool fires during agent execution' do
|
||||
@@ -222,6 +340,10 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
public_messages = conversation.messages.outgoing.where(private: false)
|
||||
expect(public_messages.count).to eq(1)
|
||||
expect(public_messages.last.content).to eq(I18n.t('conversations.captain.handoff'))
|
||||
expect(public_messages.last.additional_attributes.dig('captain', 'run')).to eq({
|
||||
'messages' => [],
|
||||
'handoff_tool_called' => true
|
||||
})
|
||||
end
|
||||
|
||||
it 'does not call bot_handoff! again when conversation is already open' do
|
||||
|
||||
@@ -24,6 +24,47 @@ RSpec.describe Message do
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
|
||||
describe '#captain_run_context' do
|
||||
it 'restores the final response from message content without mutating additional attributes' do
|
||||
message = create(
|
||||
:message,
|
||||
message_type: :outgoing,
|
||||
conversation: conversation,
|
||||
content: 'Visible response',
|
||||
additional_attributes: {
|
||||
'captain' => {
|
||||
'version' => 'v2',
|
||||
'run' => {
|
||||
'messages' => [
|
||||
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => { 'reasoning' => 'Used FAQ' },
|
||||
'agent_name' => 'assistant'
|
||||
}
|
||||
],
|
||||
'handoff_tool_called' => false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
run_context = message.captain_run_context
|
||||
|
||||
expect(run_context['messages'].last['content']).to eq({
|
||||
'reasoning' => 'Used FAQ',
|
||||
'response' => 'Visible response'
|
||||
})
|
||||
expect(message.additional_attributes.dig('captain', 'run', 'messages').last['content']).to eq({ 'reasoning' => 'Used FAQ' })
|
||||
end
|
||||
|
||||
it 'returns nil when the message has no Captain V2 run context' do
|
||||
message = create(:message, message_type: :outgoing, conversation: conversation, additional_attributes: {})
|
||||
|
||||
expect(message.captain_run_context).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#mark_pending_conversation_as_open_for_human_response' do
|
||||
let(:conversation) { create(:conversation, status: :pending) }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: conversation.account) }
|
||||
|
||||
@@ -13,7 +13,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
let(:mock_runner) { instance_double(Agents::AgentRunner) }
|
||||
let(:mock_agent) { instance_double(Agents::Agent) }
|
||||
let(:mock_scenario_agent) { instance_double(Agents::Agent) }
|
||||
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil) }
|
||||
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil, messages: []) }
|
||||
|
||||
let(:message_history) do
|
||||
[
|
||||
@@ -167,13 +167,67 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
it 'processes and formats agent result' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil, 'handoff_tool_called' => false })
|
||||
expect(result).to eq({
|
||||
'response' => 'Test response',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => false,
|
||||
'run_context' => {
|
||||
'messages' => [],
|
||||
'handoff_tool_called' => false
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
it 'normalizes run messages and removes duplicated final response' do
|
||||
runner_messages = [
|
||||
{
|
||||
role: :assistant,
|
||||
content: '',
|
||||
agent_name: 'assistant',
|
||||
tool_calls: [{ id: 'call_1', name: 'captain--tools--faq_lookup', arguments: { query: 'billing' } }]
|
||||
},
|
||||
{ role: :tool, content: 'FAQ result', tool_call_id: 'call_1' },
|
||||
{
|
||||
role: :assistant,
|
||||
content: { response: 'Test response', reasoning: 'Answered from FAQ' },
|
||||
agent_name: scenario.handoff_key
|
||||
}
|
||||
]
|
||||
allow(mock_result).to receive(:messages).and_return(runner_messages)
|
||||
allow(mock_result).to receive(:context).and_return({ current_agent: scenario.handoff_key })
|
||||
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result['agent_name']).to eq(scenario.handoff_key)
|
||||
expect(result['run_context']).to eq({
|
||||
'messages' => [
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => '',
|
||||
'agent_name' => 'assistant',
|
||||
'tool_calls' => [
|
||||
{
|
||||
'id' => 'call_1',
|
||||
'name' => 'captain--tools--faq_lookup',
|
||||
'arguments' => { 'query' => 'billing' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
|
||||
{
|
||||
'role' => 'assistant',
|
||||
'content' => { 'reasoning' => 'Answered from FAQ' },
|
||||
'agent_name' => scenario.handoff_key
|
||||
}
|
||||
],
|
||||
'handoff_tool_called' => false
|
||||
})
|
||||
end
|
||||
|
||||
context 'when handoff tool was called during agent execution' do
|
||||
let(:runner_context) { { captain_v2_handoff_tool_called: true } }
|
||||
let(:mock_result) do
|
||||
instance_double(Agents::RunResult, output: { 'response' => 'Let me connect you' }, context: runner_context)
|
||||
instance_double(Agents::RunResult, output: { 'response' => 'Let me connect you' }, context: runner_context, messages: [])
|
||||
end
|
||||
|
||||
it 'includes handoff_tool_called flag in response' do
|
||||
@@ -182,7 +236,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
expect(result).to eq({
|
||||
'response' => 'Let me connect you',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => true
|
||||
'handoff_tool_called' => true,
|
||||
'run_context' => {
|
||||
'messages' => [],
|
||||
'handoff_tool_called' => true
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -203,7 +261,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
end
|
||||
|
||||
context 'when agent result is a string' do
|
||||
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) }
|
||||
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil, messages: []) }
|
||||
|
||||
it 'formats string response correctly' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
@@ -212,7 +270,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
'response' => 'Simple string response',
|
||||
'reasoning' => 'Processed by agent',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => false
|
||||
'handoff_tool_called' => false,
|
||||
'run_context' => {
|
||||
'messages' => [],
|
||||
'handoff_tool_called' => false
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -306,6 +368,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
)
|
||||
end
|
||||
|
||||
it 'preserves structured assistant content in conversation history' do
|
||||
structured_content = { 'response' => 'Visible answer', 'reasoning' => 'Used FAQ', 'custom' => { 'score' => 0.9 } }
|
||||
context = service.send(:build_context, [
|
||||
{ role: 'assistant', content: structured_content, agent_name: 'assistant' }
|
||||
])
|
||||
|
||||
expect(context[:conversation_history].first[:content]).to eq(structured_content)
|
||||
end
|
||||
|
||||
context 'with multimodal content' do
|
||||
let(:multimodal_content) do
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user