diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index adfe4b512..243140191 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -376,20 +376,24 @@ class Captain::Assistant::AgentRunnerService end # Builds the orchestrator agent and wraps its instructions lambda with - # optional Captain ContactMemory recall. Delegates to MemoryPromptInjector. + # optional Captain ContactMemory recall. Uses Agents::Agent#clone (public + # API) so the original agent stays immutable and thread-safe. 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 + original_instructions = default_agent.instructions + default_agent.clone(instructions: build_wrapped_instructions(original_instructions)) + end - default_agent.instance_variable_set(:@instructions, wrapped) - default_agent + # Returns a lambda that delegates to build_system_prompt_with_memory, so + # the runtime path and the test-facing helper share ONE implementation. + def build_wrapped_instructions(original_instructions) + lambda do |context| + base_prompt = original_instructions.respond_to?(:call) ? original_instructions.call(context) : original_instructions.to_s + query_text = last_user_message_from_context(context) + build_system_prompt_with_memory(query_text, base_prompt) + end end def last_user_message_from_context(context) @@ -400,10 +404,15 @@ class Captain::Assistant::AgentRunnerService 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) + # Canonical method: takes the query + pre-built base prompt and returns + # the final prompt with an optional block appended. + # Both the runtime lambda and specs call through this method. + def build_system_prompt_with_memory(query_text, base_prompt) + memory_injector.append_memory_block(base_prompt, query_text) end + # Memoized per service instance. Safe because AgentRunnerService is + # constructed fresh per conversation turn. def memory_injector @memory_injector ||= Captain::Assistant::MemoryPromptInjector.new(conversation: @conversation) end diff --git a/spec/enterprise/integration/captain_semantic_memory_spec.rb b/spec/enterprise/integration/captain_semantic_memory_spec.rb index 6f91ba320..a9b5b3acf 100644 --- a/spec/enterprise/integration/captain_semantic_memory_spec.rb +++ b/spec/enterprise/integration/captain_semantic_memory_spec.rb @@ -17,12 +17,16 @@ RSpec.describe 'Captain semantic memory integration' do end it 'injects block into system prompt when recall flag is on' do - expect(runner.send(:build_system_prompt_with_memory, 'Hi there')).to include('') + result = runner.send(:build_system_prompt_with_memory, 'Hi there', 'base_system_prompt') + expect(result).to include('') + expect(result).to include('base_system_prompt') 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('') + result = runner.send(:build_system_prompt_with_memory, 'Hi there', 'base_system_prompt') + expect(result).not_to include('') + expect(result).to eq('base_system_prompt') end end # rubocop:enable RSpec/DescribeClass