diff --git a/letta/services/archive_manager.py b/letta/services/archive_manager.py index 9f98721d..d18266c5 100644 --- a/letta/services/archive_manager.py +++ b/letta/services/archive_manager.py @@ -5,6 +5,7 @@ from sqlalchemy import select from letta.helpers.tpuf_client import should_use_tpuf from letta.log import get_logger from letta.orm import ArchivalPassage, Archive as ArchiveModel, ArchivesAgents +from letta.otel.tracing import trace_method from letta.schemas.archive import Archive as PydanticArchive from letta.schemas.enums import VectorDBProvider from letta.schemas.user import User as PydanticUser @@ -19,6 +20,7 @@ class ArchiveManager: """Manager class to handle business logic related to Archives.""" @enforce_types + @trace_method def create_archive( self, name: str, @@ -44,6 +46,7 @@ class ArchiveManager: raise @enforce_types + @trace_method async def create_archive_async( self, name: str, @@ -69,6 +72,7 @@ class ArchiveManager: raise @enforce_types + @trace_method async def get_archive_by_id_async( self, archive_id: str, @@ -84,6 +88,7 @@ class ArchiveManager: return archive.to_pydantic() @enforce_types + @trace_method def attach_agent_to_archive( self, agent_id: str, @@ -113,6 +118,7 @@ class ArchiveManager: session.commit() @enforce_types + @trace_method async def attach_agent_to_archive_async( self, agent_id: str, @@ -148,6 +154,7 @@ class ArchiveManager: await session.commit() @enforce_types + @trace_method async def get_default_archive_for_agent_async( self, agent_id: str, @@ -179,6 +186,24 @@ class ArchiveManager: return None @enforce_types + @trace_method + async def delete_archive_async( + self, + archive_id: str, + actor: PydanticUser = None, + ) -> None: + """Delete an archive permanently.""" + async with db_registry.async_session() as session: + archive_model = await ArchiveModel.read_async( + db_session=session, + identifier=archive_id, + actor=actor, + ) + await archive_model.hard_delete_async(session, actor=actor) + logger.info(f"Deleted archive {archive_id}") + + @enforce_types + @trace_method async def get_or_create_default_archive_for_agent_async( self, agent_id: str, @@ -187,6 +212,8 @@ class ArchiveManager: ) -> PydanticArchive: """Get the agent's default archive, creating one if it doesn't exist.""" # First check if agent has any archives + from sqlalchemy.exc import IntegrityError + from letta.services.agent_manager import AgentManager agent_manager = AgentManager() @@ -215,17 +242,38 @@ class ArchiveManager: actor=actor, ) - # Attach the agent to the archive as owner - await self.attach_agent_to_archive_async( - agent_id=agent_id, - archive_id=archive.id, - is_owner=True, - actor=actor, - ) + try: + # Attach the agent to the archive as owner + await self.attach_agent_to_archive_async( + agent_id=agent_id, + archive_id=archive.id, + is_owner=True, + actor=actor, + ) + return archive + except IntegrityError: + # race condition: another concurrent request already created and attached an archive + # clean up the orphaned archive we just created + logger.info(f"Race condition detected for agent {agent_id}, cleaning up orphaned archive {archive.id}") + await self.delete_archive_async(archive_id=archive.id, actor=actor) - return archive + # fetch the existing archive that was created by the concurrent request + archive_ids = await agent_manager.get_agent_archive_ids_async( + agent_id=agent_id, + actor=actor, + ) + if archive_ids: + archive = await self.get_archive_by_id_async( + archive_id=archive_ids[0], + actor=actor, + ) + return archive + else: + # this shouldn't happen, but if it does, re-raise + raise @enforce_types + @trace_method def get_or_create_default_archive_for_agent( self, agent_id: str, @@ -269,6 +317,7 @@ class ArchiveManager: return archive_model.to_pydantic() @enforce_types + @trace_method async def get_agents_for_archive_async( self, archive_id: str, @@ -280,6 +329,7 @@ class ArchiveManager: return [row[0] for row in result.fetchall()] @enforce_types + @trace_method async def get_agent_from_passage_async( self, passage_id: str, @@ -309,6 +359,7 @@ class ArchiveManager: return agent_ids[0] @enforce_types + @trace_method async def get_or_set_vector_db_namespace_async( self, archive_id: str, diff --git a/tests/integration_test_turbopuffer.py b/tests/integration_test_turbopuffer.py index b820f701..e1c4bf92 100644 --- a/tests/integration_test_turbopuffer.py +++ b/tests/integration_test_turbopuffer.py @@ -1933,23 +1933,6 @@ class TestNamespaceTracking: namespace2 = await server.archive_manager.get_or_set_vector_db_namespace_async(archive.id) assert namespace == namespace2 - @pytest.mark.asyncio - async def test_agent_namespace_tracking(self, server, default_user, sarah_agent, enable_message_embedding): - """Test that agent message namespaces are properly tracked in database""" - # Get namespace - should be generated and stored - namespace = await server.agent_manager.get_or_set_vector_db_namespace_async(default_user.organization_id) - - # Should have messages_org_ prefix and environment suffix - expected_prefix = "messages_" - assert namespace.startswith(expected_prefix) - assert default_user.organization_id in namespace - if settings.environment: - assert settings.environment.lower() in namespace - - # Call again - should return same namespace from database - namespace2 = await server.agent_manager.get_or_set_vector_db_namespace_async(default_user.organization_id) - assert namespace == namespace2 - @pytest.mark.asyncio async def test_namespace_consistency_with_tpuf_client(self, server, default_user, enable_turbopuffer): """Test that the namespace from managers matches what tpuf_client would generate""" diff --git a/tests/test_agent_files/test_basic_agent_with_blocks_tools_messages_v2.af b/tests/test_agent_files/test_basic_agent_with_blocks_tools_messages_v2.af index 2971bcf0..6db6511b 100644 --- a/tests/test_agent_files/test_basic_agent_with_blocks_tools_messages_v2.af +++ b/tests/test_agent_files/test_basic_agent_with_blocks_tools_messages_v2.af @@ -1,7 +1,7 @@ { "agents": [ { - "name": "test_export_import_5297564a-d35a-4b42-9572-1ae313041430", + "name": "test_export_import_f72735f5-a08b-4ff0-8f8e-761af82dee33", "memory_blocks": [], "tools": [], "tool_ids": [ @@ -21,7 +21,7 @@ ], "tool_rules": [ { - "tool_name": "memory_insert", + "tool_name": "conversation_search", "type": "continue_loop", "prompt_template": "\n{{ tool_name }} requires continuing your response when called\n" }, @@ -30,15 +30,15 @@ "type": "continue_loop", "prompt_template": "\n{{ tool_name }} requires continuing your response when called\n" }, + { + "tool_name": "memory_insert", + "type": "continue_loop", + "prompt_template": "\n{{ tool_name }} requires continuing your response when called\n" + }, { "tool_name": "send_message", "type": "exit_loop", "prompt_template": "\n{{ tool_name }} ends your response (yields control) when called\n" - }, - { - "tool_name": "conversation_search", - "type": "continue_loop", - "prompt_template": "\n{{ tool_name }} requires continuing your response when called\n" } ], "tags": [ @@ -59,13 +59,14 @@ "put_inner_thoughts_in_kwargs": true, "handle": "openai/gpt-4.1-mini", "temperature": 0.7, - "max_tokens": 4096, + "max_tokens": null, "enable_reasoner": true, "reasoning_effort": null, "max_reasoning_tokens": 0, "frequency_penalty": 1.0, "compatibility_type": null, - "verbosity": "medium" + "verbosity": null, + "tier": null }, "embedding_config": { "embedding_endpoint_type": "openai", @@ -122,11 +123,12 @@ ], "messages": [ { + "type": "message", "role": "system", "content": [ { "type": "text", - "text": "You are a helpful assistant specializing in data analysis and mathematical computations.\n\n\nThe following memory blocks are currently engaged in your core memory unit:\n\n\n\nThe persona block: Stores details about your current persona, guiding how you behave and respond. This helps you to maintain consistency and personality in your interactions.\n\n\n- chars_current=195\n- chars_limit=8000\n\n\n# NOTE: Line numbers shown below are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.\nLine 1: You are Alex, a data analyst and mathematician who helps users with calculations and insights. You have extensive experience in statistical analysis and prefer to provide clear, accurate results.\n\n\n\n\n\nThe human block: Stores key details about the person you are conversing with, allowing for more personalized and friend-like conversation.\n\n\n- chars_current=175\n- chars_limit=4000\n\n\n# NOTE: Line numbers shown below are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.\nLine 1: username: sarah_researcher\nLine 2: occupation: data scientist\nLine 3: interests: machine learning, statistics, fibonacci sequences\nLine 4: preferred_communication: detailed explanations with examples\n\n\n\n\n\nNone\n\n\n- chars_current=210\n- chars_limit=6000\n\n\n# NOTE: Line numbers shown below are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.\nLine 1: Current project: Building predictive models for financial markets. Sarah is working on sequence analysis and pattern recognition. Recently interested in mathematical sequences like Fibonacci for trend analysis.\n\n\n\n\n\n\nThe following constraints define rules for tool usage and guide desired behavior. These rules must be followed to ensure proper tool execution and workflow. A single response may contain multiple tool calls.\n\n\nmemory_insert requires continuing your response when called\n\n\nmemory_replace requires continuing your response when called\n\n\nconversation_search requires continuing your response when called\n\n\nsend_message ends your response (yields control) when called\n\n\n\n\n\n\n- The current time is: 2025-08-21 05:49:47 PM UTC+0000\n- Memory blocks were last modified: 2025-08-21 05:49:47 PM UTC+0000\n- -1 previous messages between you and the user are stored in recall memory (use tools to access them)\n- 2 total memories you created are stored in archival memory (use tools to access them)\n" + "text": "You are a helpful assistant specializing in data analysis and mathematical computations.\n\n\nThe following memory blocks are currently engaged in your core memory unit:\n\n\n\nNone\n\n\n- chars_current=210\n- chars_limit=6000\n\n\n# NOTE: Line numbers shown below are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.\nLine 1: Current project: Building predictive models for financial markets. Sarah is working on sequence analysis and pattern recognition. Recently interested in mathematical sequences like Fibonacci for trend analysis.\n\n\n\n\n\nThe human block: Stores key details about the person you are conversing with, allowing for more personalized and friend-like conversation.\n\n\n- chars_current=175\n- chars_limit=4000\n\n\n# NOTE: Line numbers shown below are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.\nLine 1: username: sarah_researcher\nLine 2: occupation: data scientist\nLine 3: interests: machine learning, statistics, fibonacci sequences\nLine 4: preferred_communication: detailed explanations with examples\n\n\n\n\n\nThe persona block: Stores details about your current persona, guiding how you behave and respond. This helps you to maintain consistency and personality in your interactions.\n\n\n- chars_current=195\n- chars_limit=8000\n\n\n# NOTE: Line numbers shown below are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.\nLine 1: You are Alex, a data analyst and mathematician who helps users with calculations and insights. You have extensive experience in statistical analysis and prefer to provide clear, accurate results.\n\n\n\n\n\n\nThe following constraints define rules for tool usage and guide desired behavior. These rules must be followed to ensure proper tool execution and workflow. A single response may contain multiple tool calls.\n\n\nconversation_search requires continuing your response when called\n\n\nmemory_replace requires continuing your response when called\n\n\nmemory_insert requires continuing your response when called\n\n\nsend_message ends your response (yields control) when called\n\n\n\n\n\n\n- The current system date is: September 08, 2025\n- Memory blocks were last modified: 2025-09-08 05:53:05 PM UTC+0000\n- -1 previous messages between you and the user are stored in recall memory (use tools to access them)\n- 2 total memories you created are stored in archival memory (use tools to access them)\n" } ], "name": null, @@ -140,9 +142,10 @@ "tool_calls": null, "tool_call_id": null, "tool_returns": [], - "created_at": "2025-08-21T17:49:45.737357+00:00" + "created_at": "2025-09-08T17:53:02.499443+00:00" }, { + "type": "message", "role": "assistant", "content": [ { @@ -160,7 +163,7 @@ "agent_id": "agent-0", "tool_calls": [ { - "id": "3691c2df-84e5-479c-a871-3d5da7f0a7ee", + "id": "c1ec5c60-7dbc-4b53-bfbb-c2af5d5a222f", "function": { "arguments": "{\n \"message\": \"More human than human is our motto.\"\n}", "name": "send_message" @@ -170,14 +173,15 @@ ], "tool_call_id": null, "tool_returns": [], - "created_at": "2025-08-21T17:49:45.739574+00:00" + "created_at": "2025-09-08T17:53:02.499491+00:00" }, { + "type": "message", "role": "tool", "content": [ { "type": "text", - "text": "{\n \"status\": \"OK\",\n \"message\": null,\n \"time\": \"2025-08-21 05:49:45 PM UTC+0000\"\n}" + "text": "{\n \"status\": \"OK\",\n \"message\": null,\n \"time\": \"2025-09-08 05:53:02 PM UTC+0000\"\n}" } ], "name": "send_message", @@ -189,16 +193,17 @@ "model": "gpt-4.1-mini", "agent_id": "agent-0", "tool_calls": null, - "tool_call_id": "3691c2df-84e5-479c-a871-3d5da7f0a7ee", + "tool_call_id": "c1ec5c60-7dbc-4b53-bfbb-c2af5d5a222f", "tool_returns": [], - "created_at": "2025-08-21T17:49:45.739814+00:00" + "created_at": "2025-09-08T17:53:02.499526+00:00" }, { + "type": "message", "role": "user", "content": [ { "type": "text", - "text": "{\n \"type\": \"login\",\n \"last_login\": \"Never (first login)\",\n \"time\": \"2025-08-21 05:49:45 PM UTC+0000\"\n}" + "text": "{\n \"type\": \"login\",\n \"last_login\": \"Never (first login)\",\n \"time\": \"2025-09-08 05:53:02 PM UTC+0000\"\n}" } ], "name": null, @@ -212,9 +217,10 @@ "tool_calls": null, "tool_call_id": null, "tool_returns": [], - "created_at": "2025-08-21T17:49:45.739827+00:00" + "created_at": "2025-09-08T17:53:02.499541+00:00" }, { + "type": "message", "role": "user", "content": [ { @@ -233,9 +239,10 @@ "tool_calls": null, "tool_call_id": null, "tool_returns": [], - "created_at": "2025-08-21T17:49:46.969096+00:00" + "created_at": "2025-09-08T17:53:05.017966+00:00" }, { + "type": "message", "role": "assistant", "content": [ { @@ -253,24 +260,25 @@ "agent_id": "agent-0", "tool_calls": [ { - "id": "call_CR0Pvrt3B7fUk4vz3b1MDQdR", + "id": "call_sMVo2qIYhILKKNT0YiOdsU24", "function": { - "arguments": "{\"message\": \"Test message received successfully. Let me know how I can assist you further!\", \"request_heartbeat\": false}", + "arguments": "{\"message\": \"Test message received successfully. Ready to assist you with any questions or tasks.\", \"request_heartbeat\": false}", "name": "send_message" }, "type": "function" } ], - "tool_call_id": "call_CR0Pvrt3B7fUk4vz3b1MDQdR", + "tool_call_id": "call_sMVo2qIYhILKKNT0YiOdsU24", "tool_returns": [], - "created_at": "2025-08-21T17:49:47.926707+00:00" + "created_at": "2025-09-08T17:53:06.600989+00:00" }, { + "type": "message", "role": "tool", "content": [ { "type": "text", - "text": "{\n \"status\": \"OK\",\n \"message\": \"Sent message successfully.\",\n \"time\": \"2025-08-21 05:49:47 PM UTC+0000\"\n}" + "text": "{\n \"status\": \"OK\",\n \"message\": \"Sent message successfully.\",\n \"time\": \"2025-09-08 05:53:06 PM UTC+0000\"\n}" } ], "name": "send_message", @@ -282,7 +290,7 @@ "model": "gpt-4.1-mini", "agent_id": "agent-0", "tool_calls": null, - "tool_call_id": "call_CR0Pvrt3B7fUk4vz3b1MDQdR", + "tool_call_id": "call_sMVo2qIYhILKKNT0YiOdsU24", "tool_returns": [ { "status": "success", @@ -290,7 +298,7 @@ "stderr": null } ], - "created_at": "2025-08-21T17:49:47.926951+00:00" + "created_at": "2025-09-08T17:53:06.601180+00:00" } ], "files_agents": [], @@ -305,11 +313,16 @@ "project_id": null, "template_name": null, "is_template": false, + "template_id": null, + "base_template_id": null, + "deployment_id": null, + "entity_id": null, "preserve_on_migration": false, "label": "human", "read_only": false, "description": "The human block: Stores key details about the person you are conversing with, allowing for more personalized and friend-like conversation.", "metadata": {}, + "hidden": null, "id": "block-1" }, { @@ -318,11 +331,16 @@ "project_id": null, "template_name": null, "is_template": false, + "template_id": null, + "base_template_id": null, + "deployment_id": null, + "entity_id": null, "preserve_on_migration": false, "label": "persona", "read_only": false, "description": "The persona block: Stores details about your current persona, guiding how you behave and respond. This helps you to maintain consistency and personality in your interactions.", "metadata": {}, + "hidden": null, "id": "block-0" }, { @@ -331,11 +349,16 @@ "project_id": null, "template_name": null, "is_template": false, + "template_id": null, + "base_template_id": null, + "deployment_id": null, + "entity_id": null, "preserve_on_migration": false, "label": "project_context", "read_only": false, "description": null, "metadata": {}, + "hidden": null, "id": "block-2" } ], @@ -343,10 +366,10 @@ "sources": [], "tools": [ { - "id": "tool-2", + "id": "tool-6", "tool_type": "custom", "description": "Analyze data and provide insights.", - "source_type": "python", + "source_type": "json", "name": "analyze_data", "tags": [ "analysis", @@ -381,15 +404,16 @@ "return_char_limit": 50000, "pip_requirements": null, "npm_requirements": null, + "default_requires_approval": null, "created_by_id": "user-00000000-0000-4000-8000-000000000000", "last_updated_by_id": "user-00000000-0000-4000-8000-000000000000", "metadata_": {} }, { - "id": "tool-5", + "id": "tool-2", "tool_type": "custom", "description": "Calculate the nth Fibonacci number.", - "source_type": "python", + "source_type": "json", "name": "calculate_fibonacci", "tags": [ "math", @@ -416,14 +440,15 @@ "return_char_limit": 50000, "pip_requirements": null, "npm_requirements": null, + "default_requires_approval": null, "created_by_id": "user-00000000-0000-4000-8000-000000000000", "last_updated_by_id": "user-00000000-0000-4000-8000-000000000000", "metadata_": {} }, { - "id": "tool-4", + "id": "tool-3", "tool_type": "letta_core", - "description": "Search prior conversation history using case-insensitive string matching.", + "description": "Search prior conversation history using hybrid search (text + semantic similarity).\n\nExamples:\n # Search all messages\n conversation_search(query=\"project updates\")\n\n # Search only assistant messages\n conversation_search(query=\"error handling\", roles=[\"assistant\"])\n\n # Search with date range (inclusive of both dates)\n conversation_search(query=\"meetings\", start_date=\"2024-01-15\", end_date=\"2024-01-20\")\n # This includes all messages from Jan 15 00:00:00 through Jan 20 23:59:59\n\n # Search messages from a specific day (inclusive)\n conversation_search(query=\"bug reports\", start_date=\"2024-09-04\", end_date=\"2024-09-04\")\n # This includes ALL messages from September 4, 2024\n\n # Search with specific time boundaries\n conversation_search(query=\"deployment\", start_date=\"2024-01-15T09:00\", end_date=\"2024-01-15T17:30\")\n # This includes messages from 9 AM to 5:30 PM on Jan 15\n\n # Search with limit\n conversation_search(query=\"debugging\", limit=10)\n\n Returns:\n str: Query result string containing matching messages with timestamps and content.", "source_type": "python", "name": "conversation_search", "tags": [ @@ -432,17 +457,37 @@ "source_code": null, "json_schema": { "name": "conversation_search", - "description": "Search prior conversation history using case-insensitive string matching.", + "description": "Search prior conversation history using hybrid search (text + semantic similarity).\n\nExamples:\n # Search all messages\n conversation_search(query=\"project updates\")\n\n # Search only assistant messages\n conversation_search(query=\"error handling\", roles=[\"assistant\"])\n\n # Search with date range (inclusive of both dates)\n conversation_search(query=\"meetings\", start_date=\"2024-01-15\", end_date=\"2024-01-20\")\n # This includes all messages from Jan 15 00:00:00 through Jan 20 23:59:59\n\n # Search messages from a specific day (inclusive)\n conversation_search(query=\"bug reports\", start_date=\"2024-09-04\", end_date=\"2024-09-04\")\n # This includes ALL messages from September 4, 2024\n\n # Search with specific time boundaries\n conversation_search(query=\"deployment\", start_date=\"2024-01-15T09:00\", end_date=\"2024-01-15T17:30\")\n # This includes messages from 9 AM to 5:30 PM on Jan 15\n\n # Search with limit\n conversation_search(query=\"debugging\", limit=10)\n\n Returns:\n str: Query result string containing matching messages with timestamps and content.", "parameters": { "type": "object", "properties": { "query": { "type": "string", - "description": "String to search for." + "description": "String to search for using both text matching and semantic similarity." }, - "page": { + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "assistant", + "user", + "tool" + ] + }, + "description": "Optional list of message roles to filter by." + }, + "limit": { "type": "integer", - "description": "Allows you to page through results. Only use on a follow-up query. Defaults to 0 (first page)." + "description": "Maximum number of results to return. Uses system default if not specified." + }, + "start_date": { + "type": "string", + "description": "Filter results to messages created on or after this date (INCLUSIVE). When using date-only format (e.g., \"2024-01-15\"), includes messages starting from 00:00:00 of that day. ISO 8601 format: \"YYYY-MM-DD\" or \"YYYY-MM-DDTHH:MM\". Examples: \"2024-01-15\" (from start of Jan 15), \"2024-01-15T14:30\" (from 2:30 PM on Jan 15)." + }, + "end_date": { + "type": "string", + "description": "Filter results to messages created on or before this date (INCLUSIVE). When using date-only format (e.g., \"2024-01-20\"), includes all messages from that entire day. ISO 8601 format: \"YYYY-MM-DD\" or \"YYYY-MM-DDTHH:MM\". Examples: \"2024-01-20\" (includes all of Jan 20), \"2024-01-20T17:00\" (up to 5 PM on Jan 20)." } }, "required": [ @@ -451,18 +496,19 @@ } }, "args_json_schema": null, - "return_char_limit": 1000000, + "return_char_limit": 50000, "pip_requirements": null, "npm_requirements": null, + "default_requires_approval": null, "created_by_id": "user-00000000-0000-4000-8000-000000000000", "last_updated_by_id": "user-00000000-0000-4000-8000-000000000000", "metadata_": {} }, { - "id": "tool-0", + "id": "tool-1", "tool_type": "custom", "description": "Get user preferences for a specific category.", - "source_type": "python", + "source_type": "json", "name": "get_user_preferences", "tags": [ "user", @@ -489,14 +535,15 @@ "return_char_limit": 50000, "pip_requirements": null, "npm_requirements": null, + "default_requires_approval": null, "created_by_id": "user-00000000-0000-4000-8000-000000000000", "last_updated_by_id": "user-00000000-0000-4000-8000-000000000000", "metadata_": {} }, { - "id": "tool-6", + "id": "tool-0", "tool_type": "letta_sleeptime_core", - "description": "The memory_insert command allows you to insert text at a specific location in a memory block.", + "description": "The memory_insert command allows you to insert text at a specific location in a memory block.\n\nExamples:\n # Update a block containing information about the user (append to the end of the block)\n memory_insert(label=\"customer\", new_str=\"The customer's ticket number is 12345\")\n\n # Update a block containing information about the user (insert at the beginning of the block)\n memory_insert(label=\"customer\", new_str=\"The customer's ticket number is 12345\", insert_line=0)\n\n Returns:\n Optional[str]: None is always returned as this function does not produce a response.", "source_type": "python", "name": "memory_insert", "tags": [ @@ -505,7 +552,7 @@ "source_code": null, "json_schema": { "name": "memory_insert", - "description": "The memory_insert command allows you to insert text at a specific location in a memory block.", + "description": "The memory_insert command allows you to insert text at a specific location in a memory block.\n\nExamples:\n # Update a block containing information about the user (append to the end of the block)\n memory_insert(label=\"customer\", new_str=\"The customer's ticket number is 12345\")\n\n # Update a block containing information about the user (insert at the beginning of the block)\n memory_insert(label=\"customer\", new_str=\"The customer's ticket number is 12345\", insert_line=0)\n\n Returns:\n Optional[str]: None is always returned as this function does not produce a response.", "parameters": { "type": "object", "properties": { @@ -529,17 +576,18 @@ } }, "args_json_schema": null, - "return_char_limit": 1000000, + "return_char_limit": 50000, "pip_requirements": null, "npm_requirements": null, + "default_requires_approval": null, "created_by_id": "user-00000000-0000-4000-8000-000000000000", "last_updated_by_id": "user-00000000-0000-4000-8000-000000000000", "metadata_": {} }, { - "id": "tool-3", + "id": "tool-4", "tool_type": "letta_sleeptime_core", - "description": "The memory_replace command allows you to replace a specific string in a memory block with a new string. This is used for making precise edits.", + "description": "The memory_replace command allows you to replace a specific string in a memory block with a new string. This is used for making precise edits.\n\nExamples:\n # Update a block containing information about the user\n memory_replace(label=\"human\", old_str=\"Their name is Alice\", new_str=\"Their name is Bob\")\n\n # Update a block containing a todo list\n memory_replace(label=\"todos\", old_str=\"- [ ] Step 5: Search the web\", new_str=\"- [x] Step 5: Search the web\")\n\n # Pass an empty string to\n memory_replace(label=\"human\", old_str=\"Their name is Alice\", new_str=\"\")\n\n # Bad example - do NOT add (view-only) line numbers to the args\n memory_replace(label=\"human\", old_str=\"Line 1: Their name is Alice\", new_str=\"Line 1: Their name is Bob\")\n\n # Bad example - do NOT include the number number warning either\n memory_replace(label=\"human\", old_str=\"# NOTE: Line numbers shown below are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.\\nLine 1: Their name is Alice\", new_str=\"Line 1: Their name is Bob\")\n\n # Good example - no line numbers or line number warning (they are view-only), just the text\n memory_replace(label=\"human\", old_str=\"Their name is Alice\", new_str=\"Their name is Bob\")\n\n Returns:\n str: The success message", "source_type": "python", "name": "memory_replace", "tags": [ @@ -548,7 +596,7 @@ "source_code": null, "json_schema": { "name": "memory_replace", - "description": "The memory_replace command allows you to replace a specific string in a memory block with a new string. This is used for making precise edits.", + "description": "The memory_replace command allows you to replace a specific string in a memory block with a new string. This is used for making precise edits.\n\nExamples:\n # Update a block containing information about the user\n memory_replace(label=\"human\", old_str=\"Their name is Alice\", new_str=\"Their name is Bob\")\n\n # Update a block containing a todo list\n memory_replace(label=\"todos\", old_str=\"- [ ] Step 5: Search the web\", new_str=\"- [x] Step 5: Search the web\")\n\n # Pass an empty string to\n memory_replace(label=\"human\", old_str=\"Their name is Alice\", new_str=\"\")\n\n # Bad example - do NOT add (view-only) line numbers to the args\n memory_replace(label=\"human\", old_str=\"Line 1: Their name is Alice\", new_str=\"Line 1: Their name is Bob\")\n\n # Bad example - do NOT include the number number warning either\n memory_replace(label=\"human\", old_str=\"# NOTE: Line numbers shown below are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.\\nLine 1: Their name is Alice\", new_str=\"Line 1: Their name is Bob\")\n\n # Good example - no line numbers or line number warning (they are view-only), just the text\n memory_replace(label=\"human\", old_str=\"Their name is Alice\", new_str=\"Their name is Bob\")\n\n Returns:\n str: The success message", "parameters": { "type": "object", "properties": { @@ -573,15 +621,16 @@ } }, "args_json_schema": null, - "return_char_limit": 1000000, + "return_char_limit": 50000, "pip_requirements": null, "npm_requirements": null, + "default_requires_approval": null, "created_by_id": "user-00000000-0000-4000-8000-000000000000", "last_updated_by_id": "user-00000000-0000-4000-8000-000000000000", "metadata_": {} }, { - "id": "tool-1", + "id": "tool-5", "tool_type": "letta_core", "description": "Sends a message to the human user.", "source_type": "python", @@ -607,9 +656,10 @@ } }, "args_json_schema": null, - "return_char_limit": 1000000, + "return_char_limit": 50000, "pip_requirements": null, "npm_requirements": null, + "default_requires_approval": null, "created_by_id": "user-00000000-0000-4000-8000-000000000000", "last_updated_by_id": "user-00000000-0000-4000-8000-000000000000", "metadata_": {} @@ -617,7 +667,7 @@ ], "mcp_servers": [], "metadata": { - "revision_id": "ffb17eb241fc" + "revision_id": "5b804970e6a0" }, - "created_at": "2025-08-21T17:49:48.078363+00:00" + "created_at": "2025-09-08T17:53:06.749694+00:00" } diff --git a/tests/test_managers.py b/tests/test_managers.py index eb05514f..8a668189 100644 --- a/tests/test_managers.py +++ b/tests/test_managers.py @@ -4130,6 +4130,176 @@ async def test_search_agent_archival_memory_async(disable_turbopuffer, server: S await server.passage_manager.delete_agent_passage_by_id_async(passage_id=passage.id, actor=default_user) +# ====================================================================================================================== +# Archive Manager Tests +# ====================================================================================================================== +@pytest.mark.asyncio +async def test_archive_manager_delete_archive_async(server: SyncServer, default_user): + """Test the delete_archive_async function.""" + archive = await server.archive_manager.create_archive_async( + name="test_archive_to_delete", description="This archive will be deleted", actor=default_user + ) + + retrieved_archive = await server.archive_manager.get_archive_by_id_async(archive_id=archive.id, actor=default_user) + assert retrieved_archive.id == archive.id + + await server.archive_manager.delete_archive_async(archive_id=archive.id, actor=default_user) + + with pytest.raises(Exception): + await server.archive_manager.get_archive_by_id_async(archive_id=archive.id, actor=default_user) + + +@pytest.mark.asyncio +async def test_archive_manager_get_agents_for_archive_async(server: SyncServer, default_user, sarah_agent): + """Test getting all agents that have access to an archive.""" + archive = await server.archive_manager.create_archive_async( + name="shared_archive", description="Archive shared by multiple agents", actor=default_user + ) + + agent2 = await server.agent_manager.create_agent_async( + agent_create=CreateAgent( + name="test_agent_2", + memory_blocks=[], + llm_config=LLMConfig.default_config("gpt-4o-mini"), + embedding_config=EmbeddingConfig.default_config(provider="openai"), + include_base_tools=False, + ), + actor=default_user, + ) + + await server.archive_manager.attach_agent_to_archive_async( + agent_id=sarah_agent.id, archive_id=archive.id, is_owner=True, actor=default_user + ) + + await server.archive_manager.attach_agent_to_archive_async( + agent_id=agent2.id, archive_id=archive.id, is_owner=False, actor=default_user + ) + + agent_ids = await server.archive_manager.get_agents_for_archive_async(archive_id=archive.id, actor=default_user) + + assert len(agent_ids) == 2 + assert sarah_agent.id in agent_ids + assert agent2.id in agent_ids + + # Cleanup + await server.agent_manager.delete_agent_async(agent2.id, actor=default_user) + await server.archive_manager.delete_archive_async(archive.id, actor=default_user) + + +@pytest.mark.asyncio +async def test_archive_manager_race_condition_handling(server: SyncServer, default_user, sarah_agent): + """Test that the race condition fix in get_or_create_default_archive_for_agent_async works.""" + from unittest.mock import patch + + from sqlalchemy.exc import IntegrityError + + agent = await server.agent_manager.create_agent_async( + agent_create=CreateAgent( + name="test_agent_race_condition", + memory_blocks=[], + llm_config=LLMConfig.default_config("gpt-4o-mini"), + embedding_config=EmbeddingConfig.default_config(provider="openai"), + include_base_tools=False, + ), + actor=default_user, + ) + + created_archives = [] + original_create = server.archive_manager.create_archive_async + + async def track_create(*args, **kwargs): + result = await original_create(*args, **kwargs) + created_archives.append(result) + return result + + # First, create an archive that will be attached by a "concurrent" request + concurrent_archive = await server.archive_manager.create_archive_async( + name=f"{agent.name}'s Archive", description="Default archive created automatically", actor=default_user + ) + + call_count = 0 + original_attach = server.archive_manager.attach_agent_to_archive_async + + async def failing_attach(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + # Simulate another request already attached the agent to an archive + await original_attach(agent_id=agent.id, archive_id=concurrent_archive.id, is_owner=True, actor=default_user) + # Now raise the IntegrityError as if our attempt failed + raise IntegrityError("duplicate key value violates unique constraint", None, None) + # This shouldn't be called since we already have an archive + raise Exception("Should not reach here") + + with patch.object(server.archive_manager, "create_archive_async", side_effect=track_create): + with patch.object(server.archive_manager, "attach_agent_to_archive_async", side_effect=failing_attach): + archive = await server.archive_manager.get_or_create_default_archive_for_agent_async( + agent_id=agent.id, agent_name=agent.name, actor=default_user + ) + + assert archive is not None + assert archive.id == concurrent_archive.id # Should return the existing archive + assert archive.name == f"{agent.name}'s Archive" + + # One archive was created in our attempt (but then deleted) + assert len(created_archives) == 1 + + # Verify only one archive is attached to the agent + archive_ids = await server.agent_manager.get_agent_archive_ids_async(agent_id=agent.id, actor=default_user) + assert len(archive_ids) == 1 + assert archive_ids[0] == concurrent_archive.id + + # Cleanup + await server.agent_manager.delete_agent_async(agent.id, actor=default_user) + await server.archive_manager.delete_archive_async(concurrent_archive.id, actor=default_user) + + +@pytest.mark.asyncio +async def test_archive_manager_get_agent_from_passage_async(server: SyncServer, default_user, sarah_agent): + """Test getting the agent ID that owns a passage through its archive.""" + archive = await server.archive_manager.get_or_create_default_archive_for_agent_async( + agent_id=sarah_agent.id, agent_name=sarah_agent.name, actor=default_user + ) + + passage = await server.passage_manager.create_agent_passage_async( + PydanticPassage( + text="Test passage for agent ownership", + archive_id=archive.id, + organization_id=default_user.organization_id, + embedding=[0.1], + embedding_config=DEFAULT_EMBEDDING_CONFIG, + ), + actor=default_user, + ) + + agent_id = await server.archive_manager.get_agent_from_passage_async(passage_id=passage.id, actor=default_user) + + assert agent_id == sarah_agent.id + + orphan_archive = await server.archive_manager.create_archive_async( + name="orphan_archive", description="Archive with no agents", actor=default_user + ) + + orphan_passage = await server.passage_manager.create_agent_passage_async( + PydanticPassage( + text="Orphan passage", + archive_id=orphan_archive.id, + organization_id=default_user.organization_id, + embedding=[0.1], + embedding_config=DEFAULT_EMBEDDING_CONFIG, + ), + actor=default_user, + ) + + agent_id = await server.archive_manager.get_agent_from_passage_async(passage_id=orphan_passage.id, actor=default_user) + assert agent_id is None + + # Cleanup + await server.passage_manager.delete_passage_by_id_async(passage.id, actor=default_user) + await server.passage_manager.delete_passage_by_id_async(orphan_passage.id, actor=default_user) + await server.archive_manager.delete_archive_async(orphan_archive.id, actor=default_user) + + # ====================================================================================================================== # User Manager Tests # ======================================================================================================================