feat: Fix race condition with creating archival memories in parallel [LET-4205] (#4464)
* Test archive manager and add race condition handling * Fix client tests * Remove bad test
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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": "<tool_rule>\n{{ tool_name }} requires continuing your response when called\n</tool_rule>"
|
||||
},
|
||||
@@ -30,15 +30,15 @@
|
||||
"type": "continue_loop",
|
||||
"prompt_template": "<tool_rule>\n{{ tool_name }} requires continuing your response when called\n</tool_rule>"
|
||||
},
|
||||
{
|
||||
"tool_name": "memory_insert",
|
||||
"type": "continue_loop",
|
||||
"prompt_template": "<tool_rule>\n{{ tool_name }} requires continuing your response when called\n</tool_rule>"
|
||||
},
|
||||
{
|
||||
"tool_name": "send_message",
|
||||
"type": "exit_loop",
|
||||
"prompt_template": "<tool_rule>\n{{ tool_name }} ends your response (yields control) when called\n</tool_rule>"
|
||||
},
|
||||
{
|
||||
"tool_name": "conversation_search",
|
||||
"type": "continue_loop",
|
||||
"prompt_template": "<tool_rule>\n{{ tool_name }} requires continuing your response when called\n</tool_rule>"
|
||||
}
|
||||
],
|
||||
"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<memory_blocks>\nThe following memory blocks are currently engaged in your core memory unit:\n\n<persona>\n<description>\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</description>\n<metadata>\n- chars_current=195\n- chars_limit=8000\n</metadata>\n<value>\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</value>\n</persona>\n\n<human>\n<description>\nThe human block: Stores key details about the person you are conversing with, allowing for more personalized and friend-like conversation.\n</description>\n<metadata>\n- chars_current=175\n- chars_limit=4000\n</metadata>\n<value>\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</value>\n</human>\n\n<project_context>\n<description>\nNone\n</description>\n<metadata>\n- chars_current=210\n- chars_limit=6000\n</metadata>\n<value>\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</value>\n</project_context>\n\n</memory_blocks>\n\n<tool_usage_rules>\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<tool_rule>\nmemory_insert requires continuing your response when called\n</tool_rule>\n<tool_rule>\nmemory_replace requires continuing your response when called\n</tool_rule>\n<tool_rule>\nconversation_search requires continuing your response when called\n</tool_rule>\n<tool_rule>\nsend_message ends your response (yields control) when called\n</tool_rule>\n</tool_usage_rules>\n\n\n\n<memory_metadata>\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</memory_metadata>"
|
||||
"text": "You are a helpful assistant specializing in data analysis and mathematical computations.\n\n<memory_blocks>\nThe following memory blocks are currently engaged in your core memory unit:\n\n<project_context>\n<description>\nNone\n</description>\n<metadata>\n- chars_current=210\n- chars_limit=6000\n</metadata>\n<value>\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</value>\n</project_context>\n\n<human>\n<description>\nThe human block: Stores key details about the person you are conversing with, allowing for more personalized and friend-like conversation.\n</description>\n<metadata>\n- chars_current=175\n- chars_limit=4000\n</metadata>\n<value>\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</value>\n</human>\n\n<persona>\n<description>\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</description>\n<metadata>\n- chars_current=195\n- chars_limit=8000\n</metadata>\n<value>\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</value>\n</persona>\n\n</memory_blocks>\n\n<tool_usage_rules>\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<tool_rule>\nconversation_search requires continuing your response when called\n</tool_rule>\n<tool_rule>\nmemory_replace requires continuing your response when called\n</tool_rule>\n<tool_rule>\nmemory_insert requires continuing your response when called\n</tool_rule>\n<tool_rule>\nsend_message ends your response (yields control) when called\n</tool_rule>\n</tool_usage_rules>\n\n\n\n<memory_metadata>\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</memory_metadata>"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# ======================================================================================================================
|
||||
|
||||
Reference in New Issue
Block a user