From 0020f4b866e0aacf7bf082151cf8d5ba2bd5ae96 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:48:05 -0800 Subject: [PATCH] feat: recompile system message on new conversation creation (#9508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: recompile system message on new conversation creation When a new conversation is created, the system prompt is now recompiled with the latest memory block values and metadata instead of starting with no messages. This ensures each conversation captures the current agent state at creation time. - Add _initialize_conversation_system_message to ConversationManager - Compile fresh system message using PromptGenerator during conversation creation - Add integration tests for the full workflow (modify memory → new conversation gets updated system message) - Update existing test expectations for non-empty conversation messages Fixes #9507 Co-authored-by: Sarah Wooders * refactor: deduplicate system message compilation into ConversationManager Consolidate the duplicate system message compilation logic into a single shared method `compile_and_save_system_message_for_conversation` on ConversationManager. This method accepts optional pre-loaded agent_state and message_manager to avoid redundant DB loads when callers already have them. - Renamed _initialize_conversation_system_message → compile_and_save_system_message_for_conversation (public, reusable) - Added optional agent_state and message_manager params - Replaced 40-line duplicate in helpers.py with a 7-line call to the shared method - Method returns the persisted system message for caller use Co-authored-by: Sarah Wooders --------- Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com> Co-authored-by: Sarah Wooders --- letta/agents/helpers.py | 41 +----- letta/services/conversation_manager.py | 97 +++++++++++++- tests/integration_test_conversations_sdk.py | 135 +++++++++++++++++++- 3 files changed, 235 insertions(+), 38 deletions(-) diff --git a/letta/agents/helpers.py b/letta/agents/helpers.py index 28f5d304..efc8f37f 100644 --- a/letta/agents/helpers.py +++ b/letta/agents/helpers.py @@ -192,44 +192,15 @@ async def _prepare_in_context_messages_no_persist_async( # Otherwise, include the full list of messages from the conversation current_in_context_messages = await message_manager.get_messages_by_ids_async(message_ids=message_ids, actor=actor) else: - # No messages in conversation yet - compile a new system message for this conversation - # Each conversation gets its own system message (captures memory state at conversation start) - from letta.prompts.prompt_generator import PromptGenerator - from letta.services.passage_manager import PassageManager - - num_messages = await message_manager.size_async(actor=actor, agent_id=agent_state.id) - passage_manager = PassageManager() - num_archival_memories = await passage_manager.agent_passage_size_async(actor=actor, agent_id=agent_state.id) - - system_message_str = await PromptGenerator.compile_system_message_async( - system_prompt=agent_state.system, - in_context_memory=agent_state.memory, - in_context_memory_last_edit=get_utc_time(), - timezone=agent_state.timezone, - user_defined_variables=None, - append_icm_if_missing=True, - previous_message_count=num_messages, - archival_memory_size=num_archival_memories, - sources=agent_state.sources, - max_files_open=agent_state.max_files_open, - ) - system_message = Message.dict_to_message( - agent_id=agent_state.id, - model=agent_state.llm_config.model, - openai_message_dict={"role": "system", "content": system_message_str}, - ) - - # Persist the new system message - persisted_messages = await message_manager.create_many_messages_async([system_message], actor=actor) - system_message = persisted_messages[0] - - # Add it to the conversation tracking - await conversation_manager.add_messages_to_conversation( + # No messages in conversation yet (fallback) - compile a new system message + # Normally this is handled at conversation creation time, but this covers + # edge cases where a conversation exists without a system message. + system_message = await conversation_manager.compile_and_save_system_message_for_conversation( conversation_id=conversation_id, agent_id=agent_state.id, - message_ids=[system_message.id], actor=actor, - starting_position=0, + agent_state=agent_state, + message_manager=message_manager, ) current_in_context_messages = [system_message] diff --git a/letta/services/conversation_manager.py b/letta/services/conversation_manager.py index 3b95a2e6..f499c587 100644 --- a/letta/services/conversation_manager.py +++ b/letta/services/conversation_manager.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from sqlalchemy import and_, asc, delete, desc, func, nulls_last, or_, select from letta.errors import LettaInvalidArgumentError +from letta.helpers.datetime_helpers import get_utc_time from letta.orm.agent import Agent as AgentModel from letta.orm.block import Block as BlockModel from letta.orm.blocks_conversations import BlocksConversations @@ -73,7 +74,101 @@ class ConversationManager: pydantic_conversation = conversation.to_pydantic() pydantic_conversation.isolated_block_ids = isolated_block_ids - return pydantic_conversation + + # Compile and persist the initial system message for this conversation + # This ensures the conversation captures the latest memory block state at creation time + await self.compile_and_save_system_message_for_conversation( + conversation_id=pydantic_conversation.id, + agent_id=agent_id, + actor=actor, + ) + + return pydantic_conversation + + @trace_method + async def compile_and_save_system_message_for_conversation( + self, + conversation_id: str, + agent_id: str, + actor: PydanticUser, + agent_state: Optional["AgentState"] = None, + message_manager: Optional[object] = None, + ) -> PydanticMessage: + """Compile and persist the initial system message for a conversation. + + This recompiles the system prompt with the latest memory block values + and metadata, ensuring the conversation starts with an up-to-date + system message. + + This is the single source of truth for creating a conversation's system + message — used both at conversation creation time and as a fallback + when a conversation has no messages yet. + + Args: + conversation_id: The conversation to add the system message to + agent_id: The agent this conversation belongs to + actor: The user performing the action + agent_state: Optional pre-loaded agent state (avoids redundant DB load) + message_manager: Optional pre-loaded MessageManager instance + + Returns: + The persisted system message + """ + # Lazy imports to avoid circular dependencies + from letta.prompts.prompt_generator import PromptGenerator + from letta.services.message_manager import MessageManager + from letta.services.passage_manager import PassageManager + + if message_manager is None: + message_manager = MessageManager() + + if agent_state is None: + from letta.services.agent_manager import AgentManager + + agent_state = await AgentManager().get_agent_by_id_async( + agent_id=agent_id, + include_relationships=["memory", "sources"], + actor=actor, + ) + + passage_manager = PassageManager() + num_messages = await message_manager.size_async(actor=actor, agent_id=agent_id) + num_archival_memories = await passage_manager.agent_passage_size_async(actor=actor, agent_id=agent_id) + + # Compile the system message with current memory state + system_message_str = await PromptGenerator.compile_system_message_async( + system_prompt=agent_state.system, + in_context_memory=agent_state.memory, + in_context_memory_last_edit=get_utc_time(), + timezone=agent_state.timezone, + user_defined_variables=None, + append_icm_if_missing=True, + previous_message_count=num_messages, + archival_memory_size=num_archival_memories, + sources=agent_state.sources, + max_files_open=agent_state.max_files_open, + ) + + system_message = PydanticMessage.dict_to_message( + agent_id=agent_id, + model=agent_state.llm_config.model, + openai_message_dict={"role": "system", "content": system_message_str}, + ) + + # Persist the new system message + persisted_messages = await message_manager.create_many_messages_async([system_message], actor=actor) + system_message = persisted_messages[0] + + # Add it to the conversation tracking at position 0 + await self.add_messages_to_conversation( + conversation_id=conversation_id, + agent_id=agent_id, + message_ids=[system_message.id], + actor=actor, + starting_position=0, + ) + + return system_message @enforce_types @trace_method diff --git a/tests/integration_test_conversations_sdk.py b/tests/integration_test_conversations_sdk.py index 129e0ecf..45462d71 100644 --- a/tests/integration_test_conversations_sdk.py +++ b/tests/integration_test_conversations_sdk.py @@ -62,12 +62,14 @@ class TestConversationsSDK: # Create a conversation created = client.conversations.create(agent_id=agent.id) - # Retrieve it (should have empty in_context_message_ids initially) + # Retrieve it (should have system message from creation) retrieved = client.conversations.retrieve(conversation_id=created.id) assert retrieved.id == created.id assert retrieved.agent_id == created.agent_id - assert retrieved.in_context_message_ids == [] + # Conversation should have 1 system message immediately after creation + assert len(retrieved.in_context_message_ids) == 1 + assert retrieved.in_context_message_ids[0].startswith("message-") # Send a message to the conversation list( @@ -834,3 +836,132 @@ class TestConversationCompact: ) assert response.status_code == 404 + + +class TestConversationSystemMessageRecompilation: + """Tests that verify the system message is recompiled with latest memory state on new conversation creation.""" + + def test_new_conversation_recompiles_system_message_with_updated_memory(self, client: Letta, server_url: str): + """Test the full workflow: + 1. Agent is created + 2. Send message to agent (through a conversation) + 3. Modify the memory block -> check system message is NOT updated with the modified value + 4. Create a new conversation + 5. Check new conversation system message DOES have the modified value + """ + unique_marker = f"UNIQUE_MARKER_{uuid.uuid4().hex[:8]}" + + # Step 1: Create an agent with known memory blocks + agent = client.agents.create( + name=f"test_sys_msg_recompile_{uuid.uuid4().hex[:8]}", + model="openai/gpt-4o-mini", + embedding="openai/text-embedding-3-small", + memory_blocks=[ + {"label": "human", "value": "The user is a test user."}, + {"label": "persona", "value": "You are a helpful assistant."}, + ], + ) + + try: + # Step 2: Create a conversation and send a message to it + conv1 = client.conversations.create(agent_id=agent.id) + + list( + client.conversations.messages.create( + conversation_id=conv1.id, + messages=[{"role": "user", "content": "Hello, just a quick test."}], + ) + ) + + # Verify the conversation has messages including a system message + conv1_messages = client.conversations.messages.list( + conversation_id=conv1.id, + order="asc", + ) + assert len(conv1_messages) >= 3 # system + user + assistant + assert conv1_messages[0].message_type == "system_message" + + # Get the original system message content + original_system_content = conv1_messages[0].content + assert unique_marker not in original_system_content, "Marker should not be in original system message" + + # Step 3: Modify the memory block with a unique marker + client.agents.blocks.update( + agent_id=agent.id, + block_label="human", + value=f"The user is a test user. {unique_marker}", + ) + + # Verify the block was actually updated + updated_block = client.agents.blocks.retrieve(agent_id=agent.id, block_label="human") + assert unique_marker in updated_block.value + + # Check that the OLD conversation's system message is NOT updated + conv1_messages_after_update = client.conversations.messages.list( + conversation_id=conv1.id, + order="asc", + ) + old_system_content = conv1_messages_after_update[0].content + assert unique_marker not in old_system_content, ( + "Old conversation system message should NOT contain the updated memory value" + ) + + # Step 4: Create a new conversation + conv2 = client.conversations.create(agent_id=agent.id) + + # Step 5: Check the new conversation's system message has the updated value + # The system message should be compiled at creation time with the latest memory + conv2_retrieved = client.conversations.retrieve(conversation_id=conv2.id) + assert len(conv2_retrieved.in_context_message_ids) == 1, ( + f"New conversation should have exactly 1 system message, got {len(conv2_retrieved.in_context_message_ids)}" + ) + + conv2_messages = client.conversations.messages.list( + conversation_id=conv2.id, + order="asc", + ) + assert len(conv2_messages) >= 1 + assert conv2_messages[0].message_type == "system_message" + + new_system_content = conv2_messages[0].content + assert unique_marker in new_system_content, ( + f"New conversation system message should contain the updated memory value '{unique_marker}', " + f"but system message content did not include it" + ) + + finally: + client.agents.delete(agent_id=agent.id) + + def test_conversation_creation_initializes_system_message(self, client: Letta, server_url: str): + """Test that creating a conversation immediately initializes it with a system message.""" + agent = client.agents.create( + name=f"test_conv_init_{uuid.uuid4().hex[:8]}", + model="openai/gpt-4o-mini", + embedding="openai/text-embedding-3-small", + memory_blocks=[ + {"label": "human", "value": "Test user for system message init."}, + {"label": "persona", "value": "You are a helpful assistant."}, + ], + ) + + try: + # Create a conversation (without sending any messages) + conversation = client.conversations.create(agent_id=agent.id) + + # Verify the conversation has a system message immediately + retrieved = client.conversations.retrieve(conversation_id=conversation.id) + assert len(retrieved.in_context_message_ids) == 1, ( + f"Expected 1 system message after conversation creation, got {len(retrieved.in_context_message_ids)}" + ) + + # Verify the system message content contains memory block values + messages = client.conversations.messages.list( + conversation_id=conversation.id, + order="asc", + ) + assert len(messages) == 1 + assert messages[0].message_type == "system_message" + assert "Test user for system message init." in messages[0].content + + finally: + client.agents.delete(agent_id=agent.id)