feat(captain-memory): inject semantic memory into AgentRunnerService system prompt
This commit is contained in:
parent
e89b96d09b
commit
85324f594d
@ -2,6 +2,7 @@ require 'agents'
|
||||
require 'agents/instrumentation'
|
||||
require 'json'
|
||||
|
||||
# rubocop:disable Metrics/ClassLength
|
||||
class Captain::Assistant::AgentRunnerService
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
@ -58,6 +59,8 @@ class Captain::Assistant::AgentRunnerService
|
||||
private
|
||||
|
||||
def build_context(message_history)
|
||||
last_active_scenario_agent = extract_last_scenario_agent(message_history)
|
||||
|
||||
conversation_history = message_history.map do |msg|
|
||||
content = extract_text_from_content(msg[:content])
|
||||
|
||||
@ -73,14 +76,65 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
{
|
||||
session_id: "#{@assistant.account_id}_#{@conversation&.display_id}",
|
||||
# Always start each turn from the orchestrator agent so factual questions
|
||||
# don't remain trapped inside a previously active scenario agent.
|
||||
current_agent: assistant_agent_name,
|
||||
# Default: start each turn from the orchestrator (Jasmine).
|
||||
# Exception: if there is an active workflow (e.g. pending reservation),
|
||||
# continue with the last active scenario agent so multi-turn flows
|
||||
# like reservations don't get interrupted by the orchestrator stealing the turn.
|
||||
current_agent: pick_starting_agent(last_active_scenario_agent),
|
||||
conversation_history: conversation_history,
|
||||
state: build_state
|
||||
}
|
||||
end
|
||||
|
||||
# Reads the last assistant message that came from a scenario (not the orchestrator).
|
||||
# Returns nil if no scenario was recently active.
|
||||
def extract_last_scenario_agent(message_history)
|
||||
return nil if message_history.blank?
|
||||
|
||||
orchestrator = assistant_agent_name
|
||||
last_assistant = message_history.reverse.find do |msg|
|
||||
next false unless msg[:role] == 'assistant'
|
||||
|
||||
name = msg[:agent_name].to_s
|
||||
name.present? && name != orchestrator
|
||||
end
|
||||
last_assistant&.dig(:agent_name)
|
||||
end
|
||||
|
||||
# Picks which agent should start the next turn:
|
||||
# - If there is an active workflow on this conversation (e.g. reservation pending),
|
||||
# continue with the last scenario agent that was active.
|
||||
# - Otherwise, default to the orchestrator (Jasmine).
|
||||
def pick_starting_agent(last_scenario_agent)
|
||||
default = assistant_agent_name
|
||||
return default if @conversation.blank?
|
||||
return default if last_scenario_agent.blank?
|
||||
|
||||
return last_scenario_agent if active_workflow?
|
||||
|
||||
default
|
||||
end
|
||||
|
||||
# Detects if the conversation is mid-workflow (e.g. ongoing reservation).
|
||||
# Safe to extend: any condition here keeps the user inside the active scenario.
|
||||
def active_workflow?
|
||||
return false if @conversation.blank?
|
||||
|
||||
if defined?(Captain::Reservation)
|
||||
pending_reservation = Captain::Reservation
|
||||
.where(conversation_id: @conversation.id)
|
||||
.where.not(status: %w[cancelled completed])
|
||||
.exists?
|
||||
return true if pending_reservation
|
||||
end
|
||||
|
||||
label_list = @conversation.respond_to?(:label_list) ? @conversation.label_list : nil
|
||||
Array(label_list).any? { |label| label.to_s.match?(/aguardando|pending|em_andamento/i) }
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[Captain V2] active_workflow? check failed: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
def assistant_agent_name
|
||||
@assistant.name.to_s.parameterize(separator: '_')
|
||||
end
|
||||
@ -120,6 +174,8 @@ class Captain::Assistant::AgentRunnerService
|
||||
def enforce_faq_guardrail(response, result, original_query:)
|
||||
return response unless faq_feature_enabled?
|
||||
return response if response['response'] == 'conversation_handoff'
|
||||
# Scenario agents have authoritative data in their own instructions — skip guardrail
|
||||
return response if response['agent_name'].present? && response['agent_name'] != assistant_agent_name
|
||||
|
||||
guardrail_reason = faq_guardrail_reason(response['response'])
|
||||
return response if guardrail_reason.blank?
|
||||
@ -254,6 +310,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
}
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def extract_json_objects(text)
|
||||
objects = []
|
||||
start_index = nil
|
||||
@ -291,6 +348,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
objects
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
@ -308,7 +366,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
assistant_agent = @assistant.agent
|
||||
assistant_agent = build_orchestrator_agent_with_memory
|
||||
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
|
||||
|
||||
assistant_agent.register_handoffs(*scenario_agents) if scenario_agents.any?
|
||||
@ -317,6 +375,39 @@ class Captain::Assistant::AgentRunnerService
|
||||
[assistant_agent] + scenario_agents
|
||||
end
|
||||
|
||||
# Builds the orchestrator agent and wraps its instructions lambda with
|
||||
# optional Captain ContactMemory recall. Delegates to MemoryPromptInjector.
|
||||
def build_orchestrator_agent_with_memory
|
||||
default_agent = @assistant.agent
|
||||
return default_agent unless memory_injector.recall_enabled?
|
||||
|
||||
original_instructions = default_agent.instance_variable_get(:@instructions)
|
||||
injector = memory_injector
|
||||
wrapped = lambda do |context|
|
||||
base_prompt = original_instructions.respond_to?(:call) ? original_instructions.call(context) : original_instructions.to_s
|
||||
injector.append_memory_block(base_prompt, last_user_message_from_context(context))
|
||||
end
|
||||
|
||||
default_agent.instance_variable_set(:@instructions, wrapped)
|
||||
default_agent
|
||||
end
|
||||
|
||||
def last_user_message_from_context(context)
|
||||
history = context&.context&.dig(:conversation_history) || []
|
||||
last_user = history.reverse.find { |msg| msg[:role].to_sym == :user }
|
||||
return '' if last_user.blank?
|
||||
|
||||
extract_text_from_content(last_user[:content], as_string: true).to_s
|
||||
end
|
||||
|
||||
def build_system_prompt_with_memory(message_text)
|
||||
memory_injector.append_memory_block(@assistant.agent_instructions(nil), message_text)
|
||||
end
|
||||
|
||||
def memory_injector
|
||||
@memory_injector ||= Captain::Assistant::MemoryPromptInjector.new(conversation: @conversation)
|
||||
end
|
||||
|
||||
def install_instrumentation(runner)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
|
||||
@ -412,3 +503,4 @@ class Captain::Assistant::AgentRunnerService
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ClassLength
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
class Captain::Assistant::MemoryPromptInjector
|
||||
def initialize(conversation:)
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def recall_enabled?
|
||||
account = @conversation&.account
|
||||
return false if account.blank?
|
||||
|
||||
account.respond_to?(:captain_contact_memory_recall_enabled?) &&
|
||||
account.captain_contact_memory_recall_enabled?
|
||||
end
|
||||
|
||||
# Wraps the given base system prompt with a <memoria_cliente> block
|
||||
# when recall is enabled and memories are found. Degrades gracefully:
|
||||
# returns the untouched base prompt on any failure or absent context.
|
||||
def append_memory_block(base_prompt, message_text)
|
||||
return base_prompt unless recall_enabled?
|
||||
return base_prompt if @conversation&.contact.blank?
|
||||
|
||||
memories = Captain::ContactMemories::RecallService.new(
|
||||
contact: @conversation.contact,
|
||||
query_text: message_text,
|
||||
unit_id: resolve_unit_id
|
||||
).call
|
||||
|
||||
memory_block = Captain::ContactMemories::PromptInjectionService.new(memories: memories).call
|
||||
return base_prompt if memory_block.blank?
|
||||
|
||||
[base_prompt, memory_block].join("\n\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_unit_id
|
||||
return nil if @conversation.blank?
|
||||
|
||||
return @conversation.captain_unit_id if @conversation.respond_to?(:captain_unit_id) && @conversation.captain_unit_id.present?
|
||||
|
||||
Captain::Unit.where(inbox_id: @conversation.inbox_id).pick(:id) if defined?(Captain::Unit)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[Captain V2] MemoryPromptInjector#resolve_unit_id failed: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
28
spec/enterprise/integration/captain_semantic_memory_spec.rb
Normal file
28
spec/enterprise/integration/captain_semantic_memory_spec.rb
Normal file
@ -0,0 +1,28 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# rubocop:disable RSpec/DescribeClass
|
||||
RSpec.describe 'Captain semantic memory integration' do
|
||||
let(:account) { create(:account, custom_attributes: attrs) }
|
||||
let(:attrs) { { 'captain_contact_memory_recall_enabled' => true } }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, contact: contact) }
|
||||
let(:runner) { Captain::Assistant::AgentRunnerService.new(assistant: assistant, conversation: conversation) }
|
||||
|
||||
before do
|
||||
create(:captain_contact_memory, account: account, contact: contact, content: 'Prefere Stilo', embedding: Array.new(1536, 0.1))
|
||||
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(
|
||||
instance_double(Captain::Llm::EmbeddingService, get_embedding: Array.new(1536, 0.1))
|
||||
)
|
||||
end
|
||||
|
||||
it 'injects <memoria_cliente> block into system prompt when recall flag is on' do
|
||||
expect(runner.send(:build_system_prompt_with_memory, 'Hi there')).to include('<memoria_cliente>')
|
||||
end
|
||||
|
||||
it 'skips injection when recall flag is off' do
|
||||
account.update!(custom_attributes: {})
|
||||
expect(runner.send(:build_system_prompt_with_memory, 'Hi there')).not_to include('<memoria_cliente>')
|
||||
end
|
||||
end
|
||||
# rubocop:enable RSpec/DescribeClass
|
||||
Loading…
Reference in New Issue
Block a user