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)