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 'agents/instrumentation'
|
||||||
require 'json'
|
require 'json'
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/ClassLength
|
||||||
class Captain::Assistant::AgentRunnerService
|
class Captain::Assistant::AgentRunnerService
|
||||||
include Integrations::LlmInstrumentationConstants
|
include Integrations::LlmInstrumentationConstants
|
||||||
|
|
||||||
@ -58,6 +59,8 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
private
|
private
|
||||||
|
|
||||||
def build_context(message_history)
|
def build_context(message_history)
|
||||||
|
last_active_scenario_agent = extract_last_scenario_agent(message_history)
|
||||||
|
|
||||||
conversation_history = message_history.map do |msg|
|
conversation_history = message_history.map do |msg|
|
||||||
content = extract_text_from_content(msg[:content])
|
content = extract_text_from_content(msg[:content])
|
||||||
|
|
||||||
@ -73,14 +76,65 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
|
|
||||||
{
|
{
|
||||||
session_id: "#{@assistant.account_id}_#{@conversation&.display_id}",
|
session_id: "#{@assistant.account_id}_#{@conversation&.display_id}",
|
||||||
# Always start each turn from the orchestrator agent so factual questions
|
# Default: start each turn from the orchestrator (Jasmine).
|
||||||
# don't remain trapped inside a previously active scenario agent.
|
# Exception: if there is an active workflow (e.g. pending reservation),
|
||||||
current_agent: assistant_agent_name,
|
# 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,
|
conversation_history: conversation_history,
|
||||||
state: build_state
|
state: build_state
|
||||||
}
|
}
|
||||||
end
|
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
|
def assistant_agent_name
|
||||||
@assistant.name.to_s.parameterize(separator: '_')
|
@assistant.name.to_s.parameterize(separator: '_')
|
||||||
end
|
end
|
||||||
@ -120,6 +174,8 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
def enforce_faq_guardrail(response, result, original_query:)
|
def enforce_faq_guardrail(response, result, original_query:)
|
||||||
return response unless faq_feature_enabled?
|
return response unless faq_feature_enabled?
|
||||||
return response if response['response'] == 'conversation_handoff'
|
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'])
|
guardrail_reason = faq_guardrail_reason(response['response'])
|
||||||
return response if guardrail_reason.blank?
|
return response if guardrail_reason.blank?
|
||||||
@ -254,6 +310,7 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/MethodLength
|
||||||
def extract_json_objects(text)
|
def extract_json_objects(text)
|
||||||
objects = []
|
objects = []
|
||||||
start_index = nil
|
start_index = nil
|
||||||
@ -291,6 +348,7 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
|
|
||||||
objects
|
objects
|
||||||
end
|
end
|
||||||
|
# rubocop:enable Metrics/MethodLength
|
||||||
|
|
||||||
def build_state
|
def build_state
|
||||||
state = {
|
state = {
|
||||||
@ -308,7 +366,7 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_and_wire_agents
|
def build_and_wire_agents
|
||||||
assistant_agent = @assistant.agent
|
assistant_agent = build_orchestrator_agent_with_memory
|
||||||
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
|
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
|
||||||
|
|
||||||
assistant_agent.register_handoffs(*scenario_agents) if scenario_agents.any?
|
assistant_agent.register_handoffs(*scenario_agents) if scenario_agents.any?
|
||||||
@ -317,6 +375,39 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
[assistant_agent] + scenario_agents
|
[assistant_agent] + scenario_agents
|
||||||
end
|
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)
|
def install_instrumentation(runner)
|
||||||
return unless ChatwootApp.otel_enabled?
|
return unless ChatwootApp.otel_enabled?
|
||||||
|
|
||||||
@ -412,3 +503,4 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
end
|
end
|
||||||
end
|
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