feat(captain-memory): inject semantic memory into AgentRunnerService system prompt

This commit is contained in:
Rodribm10 2026-04-19 01:23:03 -03:00
parent e89b96d09b
commit 85324f594d
3 changed files with 169 additions and 4 deletions

View File

@ -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

View File

@ -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

View 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