import json import logging import os import random import re import string import time import uuid from datetime import datetime, timedelta, timezone from typing import List from unittest.mock import AsyncMock, Mock, patch import pytest from _pytest.python_api import approx from anthropic.types.beta import BetaMessage from anthropic.types.beta.messages import BetaMessageBatchIndividualResponse, BetaMessageBatchSucceededResult # Import shared fixtures and constants from conftest from conftest import ( CREATE_DELAY_SQLITE, DEFAULT_EMBEDDING_CONFIG, USING_SQLITE, ) from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall, Function as OpenAIFunction from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError, InvalidRequestError from sqlalchemy.orm.exc import StaleDataError from letta.config import LettaConfig from letta.constants import ( BASE_MEMORY_TOOLS, BASE_SLEEPTIME_TOOLS, BASE_TOOLS, BASE_VOICE_SLEEPTIME_CHAT_TOOLS, BASE_VOICE_SLEEPTIME_TOOLS, BUILTIN_TOOLS, DEFAULT_ORG_ID, DEFAULT_ORG_NAME, FILES_TOOLS, LETTA_TOOL_EXECUTION_DIR, LETTA_TOOL_SET, LOCAL_ONLY_MULTI_AGENT_TOOLS, MCP_TOOL_TAG_NAME_PREFIX, MULTI_AGENT_TOOLS, ) from letta.data_sources.redis_client import NoopAsyncRedisClient, get_redis_client from letta.errors import LettaAgentNotFoundError from letta.functions.functions import derive_openai_json_schema, parse_source_code from letta.functions.mcp_client.types import MCPTool from letta.helpers import ToolRulesSolver from letta.helpers.datetime_helpers import AsyncTimer from letta.jobs.types import ItemUpdateInfo, RequestStatusUpdateInfo, StepStatusUpdateInfo from letta.orm import Base, Block from letta.orm.block_history import BlockHistory from letta.orm.errors import NoResultFound, UniqueConstraintViolationError from letta.orm.file import FileContent as FileContentModel, FileMetadata as FileMetadataModel from letta.schemas.agent import CreateAgent, InternalTemplateAgentCreate, UpdateAgent from letta.schemas.block import Block as PydanticBlock, BlockUpdate, CreateBlock from letta.schemas.embedding_config import EmbeddingConfig from letta.schemas.enums import ( ActorType, AgentStepStatus, FileProcessingStatus, JobStatus, JobType, MessageRole, ProviderType, SandboxType, StepStatus, TagMatchMode, ToolType, VectorDBProvider, ) from letta.schemas.environment_variables import SandboxEnvironmentVariableCreate, SandboxEnvironmentVariableUpdate from letta.schemas.file import FileMetadata, FileMetadata as PydanticFileMetadata from letta.schemas.identity import IdentityCreate, IdentityProperty, IdentityPropertyType, IdentityType, IdentityUpdate, IdentityUpsert from letta.schemas.job import BatchJob, Job, Job as PydanticJob, JobUpdate, LettaRequestConfig from letta.schemas.letta_message import UpdateAssistantMessage, UpdateReasoningMessage, UpdateSystemMessage, UpdateUserMessage from letta.schemas.letta_message_content import TextContent from letta.schemas.letta_stop_reason import LettaStopReason, StopReasonType from letta.schemas.llm_batch_job import AgentStepState, LLMBatchItem from letta.schemas.llm_config import LLMConfig from letta.schemas.message import Message as PydanticMessage, MessageCreate, MessageUpdate from letta.schemas.model import ModelSettings from letta.schemas.openai.chat_completion_response import UsageStatistics from letta.schemas.organization import Organization, Organization as PydanticOrganization, OrganizationUpdate from letta.schemas.passage import Passage as PydanticPassage from letta.schemas.pip_requirement import PipRequirement from letta.schemas.run import Run as PydanticRun from letta.schemas.sandbox_config import E2BSandboxConfig, LocalSandboxConfig, SandboxConfigCreate, SandboxConfigUpdate from letta.schemas.source import Source as PydanticSource, SourceUpdate from letta.schemas.tool import Tool as PydanticTool, ToolCreate, ToolUpdate from letta.schemas.tool_rule import InitToolRule from letta.schemas.user import User as PydanticUser, UserUpdate from letta.server.db import db_registry from letta.server.server import SyncServer from letta.services.block_manager import BlockManager from letta.services.helpers.agent_manager_helper import calculate_base_tools, calculate_multi_agent_tools, validate_agent_exists_async from letta.services.step_manager import FeedbackType from letta.services.summarizer.summarizer_config import CompactionSettings from letta.settings import settings, tool_settings from letta.utils import calculate_file_defaults_based_on_context_window from tests.helpers.utils import comprehensive_agent_checks, validate_context_window_overview from tests.utils import random_string # ====================================================================================================================== # Helper Functions # ====================================================================================================================== async def _count_file_content_rows(session, file_id: str) -> int: q = select(func.count()).select_from(FileContentModel).where(FileContentModel.file_id == file_id) result = await session.execute(q) return result.scalar_one() # ====================================================================================================================== # AgentManager Tests - Basic # ====================================================================================================================== async def test_validate_agent_exists_async(server: SyncServer, comprehensive_test_agent_fixture, default_user): """Test the validate_agent_exists_async helper function""" created_agent, _ = comprehensive_test_agent_fixture # test with valid agent async with db_registry.async_session() as session: # should not raise exception await validate_agent_exists_async(session, created_agent.id, default_user) # test with non-existent agent async with db_registry.async_session() as session: with pytest.raises(LettaAgentNotFoundError): await validate_agent_exists_async(session, "non-existent-id", default_user) @pytest.mark.asyncio async def test_create_get_list_agent(server: SyncServer, comprehensive_test_agent_fixture, default_user): # Test agent creation created_agent, create_agent_request = comprehensive_test_agent_fixture comprehensive_agent_checks(created_agent, create_agent_request, actor=default_user) # Test get agent get_agent = await server.agent_manager.get_agent_by_id_async(agent_id=created_agent.id, actor=default_user) comprehensive_agent_checks(get_agent, create_agent_request, actor=default_user) # Test get agent name agents = await server.agent_manager.list_agents_async(name=created_agent.name, actor=default_user) get_agent_name = agents[0] comprehensive_agent_checks(get_agent_name, create_agent_request, actor=default_user) # Test list agent list_agents = await server.agent_manager.list_agents_async(actor=default_user) assert len(list_agents) == 1 comprehensive_agent_checks(list_agents[0], create_agent_request, actor=default_user) # Test deleting the agent await server.agent_manager.delete_agent_async(get_agent.id, default_user) list_agents = await server.agent_manager.list_agents_async(actor=default_user) assert len(list_agents) == 0 @pytest.mark.asyncio async def test_create_agent_include_base_tools(server: SyncServer, default_user): """Test agent creation with include_default_source=True""" # Upsert base tools await server.tool_manager.upsert_base_tools_async(actor=default_user) memory_blocks = [CreateBlock(label="human", value="TestUser"), CreateBlock(label="persona", value="I am a test assistant")] create_agent_request = CreateAgent( name="test_default_source_agent", agent_type="memgpt_v2_agent", system="test system", memory_blocks=memory_blocks, llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), include_base_tools=True, ) # Create the agent created_agent = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) # Assert the tools exist tool_names = [t.name for t in created_agent.tools] expected_tools = calculate_base_tools(is_v2=True) assert sorted(tool_names) == sorted(expected_tools) @pytest.mark.asyncio async def test_create_agent_base_tool_rules_excluded_providers(server: SyncServer, default_user): """Test that include_base_tool_rules is overridden to False for excluded providers""" # Upsert base tools await server.tool_manager.upsert_base_tools_async(actor=default_user) memory_blocks = [CreateBlock(label="human", value="TestUser"), CreateBlock(label="persona", value="I am a test assistant")] # Test with excluded provider (openai) create_agent_request = CreateAgent( name="test_excluded_provider_agent", agent_type="memgpt_v2_agent", system="test system", memory_blocks=memory_blocks, llm_config=LLMConfig.default_config("gpt-4o-mini"), # This has model_endpoint_type="openai" embedding_config=EmbeddingConfig.default_config(provider="openai"), include_base_tool_rules=False, ) # Create the agent created_agent = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) # Assert that no base tool rules were added (since include_base_tool_rules was overridden to False) print(created_agent.tool_rules) assert created_agent.tool_rules is None or len(created_agent.tool_rules) == 0 @pytest.mark.asyncio async def test_create_agent_base_tool_rules_non_excluded_providers(server: SyncServer, default_user): """Test that include_base_tool_rules is NOT overridden for non-excluded providers""" # Upsert base tools await server.tool_manager.upsert_base_tools_async(actor=default_user) memory_blocks = [CreateBlock(label="human", value="TestUser"), CreateBlock(label="persona", value="I am a test assistant")] # Test with non-excluded provider (together) create_agent_request = CreateAgent( name="test_non_excluded_provider_agent", agent_type="memgpt_v2_agent", system="test system", memory_blocks=memory_blocks, llm_config=LLMConfig( model="llama-3.1-8b-instruct", model_endpoint_type="together", # Model doesn't match EXCLUDE_MODEL_KEYWORDS_FROM_BASE_TOOL_RULES model_endpoint="https://api.together.xyz", context_window=8192, ), embedding_config=EmbeddingConfig.default_config(provider="openai"), include_base_tool_rules=True, # Should remain True ) # Create the agent created_agent = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) # Assert that base tool rules were added (since include_base_tool_rules remained True) assert created_agent.tool_rules is not None assert len(created_agent.tool_rules) > 0 @pytest.mark.asyncio async def test_create_agent_with_model_handle_uses_correct_llm_config(server: SyncServer, default_user): """When CreateAgent.model is provided, ensure the correct handle is used to resolve llm_config. This verifies that the model handle passed by the client is forwarded into SyncServer.get_llm_config_from_handle_async and that the resulting AgentState carries an llm_config with the same handle. """ # Track the arguments used to resolve the LLM config captured_kwargs: dict = {} async def fake_get_llm_config_from_handle_async(self, actor, **kwargs): # type: ignore[override] from letta.schemas.llm_config import LLMConfig as PydanticLLMConfig captured_kwargs.update(kwargs) handle = kwargs["handle"] # Return a minimal but valid LLMConfig with the requested handle return PydanticLLMConfig( model="test-model-name", model_endpoint_type="openai", model_endpoint="https://api.openai.com/v1", context_window=8192, handle=handle, ) model_handle = "openai/gpt-4o-mini" # Patch SyncServer.get_llm_config_from_handle_async so we don't depend on provider DB state with patch.object(SyncServer, "get_llm_config_from_handle_async", new=fake_get_llm_config_from_handle_async): created_agent = await server.create_agent_async( request=CreateAgent( name="agent_with_model_handle", agent_type="memgpt_v2_agent", # Use new model handle field instead of llm_config model=model_handle, embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, ), actor=default_user, ) # Ensure we resolved the config using the provided handle assert captured_kwargs["handle"] == model_handle # And that the resulting agent's llm_config reflects the same handle assert created_agent.llm_config is not None assert created_agent.llm_config.handle == model_handle @pytest.mark.asyncio async def test_compaction_settings_model_uses_separate_llm_config_for_summarization(default_user): """When compaction_settings.model differs from the agent model, use a separate llm_config. This test exercises the summarization helpers directly to avoid external provider dependencies. It verifies that CompactionSettings.model controls the LLMConfig used for the summarizer request. """ from letta.schemas.agent import AgentState as PydanticAgentState from letta.schemas.enums import AgentType, MessageRole from letta.schemas.memory import Memory from letta.schemas.message import Message as PydanticMessage from letta.schemas.model import OpenAIModelSettings, OpenAIReasoning from letta.services.summarizer.compact import build_summarizer_llm_config # Base agent LLM config base_llm_config = LLMConfig.default_config("gpt-4o-mini") assert base_llm_config.model == "gpt-4o-mini" # Configure compaction to use a different summarizer model summarizer_handle = "openai/gpt-5-mini" summarizer_model_settings = OpenAIModelSettings( max_output_tokens=1234, temperature=0.1, reasoning=OpenAIReasoning(reasoning_effort="high"), response_format=None, ) summarizer_config = CompactionSettings( model=summarizer_handle, model_settings=summarizer_model_settings, prompt="You are a summarizer.", clip_chars=2000, mode="all", sliding_window_percentage=0.3, ) # Minimal message buffer: system + one user + one assistant messages = [ PydanticMessage( role=MessageRole.system, content=[TextContent(type="text", text="You are a helpful assistant.")], ), PydanticMessage( role=MessageRole.user, content=[TextContent(type="text", text="Hello")], ), PydanticMessage( role=MessageRole.assistant, content=[TextContent(type="text", text="Hi there")], ), ] # Build a minimal AgentState for LettaAgentV3 using the base llm_config agent_state = PydanticAgentState( id="agent-test-compaction-llm-config", name="test-agent", system="You are a helpful assistant.", agent_type=AgentType.letta_v1_agent, llm_config=base_llm_config, embedding_config=EmbeddingConfig.default_config(provider="openai"), model=None, embedding=None, model_settings=None, compaction_settings=summarizer_config, response_format=None, description=None, metadata=None, memory=Memory(blocks=[]), blocks=[], tools=[], sources=[], tags=[], tool_exec_environment_variables=[], secrets=[], project_id=None, template_id=None, base_template_id=None, deployment_id=None, entity_id=None, identity_ids=[], identities=[], message_ids=[], message_buffer_autoclear=False, enable_sleeptime=None, multi_agent_group=None, managed_group=None, last_run_completion=None, last_run_duration_ms=None, last_stop_reason=None, timezone="UTC", max_files_open=None, per_file_view_window_char_limit=None, hidden=None, created_by_id=None, last_updated_by_id=None, created_at=None, updated_at=None, tool_rules=None, ) # Use the shared function to derive summarizer llm_config summarizer_llm_config = await build_summarizer_llm_config( agent_llm_config=agent_state.llm_config, summarizer_config=agent_state.compaction_settings, actor=default_user, ) # Agent model remains the base model assert agent_state.llm_config.model == "gpt-4o-mini" # Summarizer config should use the handle/model from compaction_settings assert summarizer_llm_config.handle == summarizer_handle assert summarizer_llm_config.model == "gpt-5-mini" # And should reflect overrides from model_settings assert summarizer_llm_config.max_tokens == 1234 assert summarizer_llm_config.temperature == 0.1 @pytest.mark.asyncio async def test_calculate_multi_agent_tools(set_letta_environment): """Test that calculate_multi_agent_tools excludes local-only tools in production.""" result = calculate_multi_agent_tools() if settings.environment == "prod": # Production environment should exclude local-only tools expected_tools = set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS) assert result == expected_tools, "Production should exclude local-only multi-agent tools" assert not set(LOCAL_ONLY_MULTI_AGENT_TOOLS).intersection(result), "Production should not include local-only tools" # Verify specific tools assert "send_message_to_agent_and_wait_for_reply" in result, "Standard multi-agent tools should be in production" assert "send_message_to_agents_matching_tags" in result, "Standard multi-agent tools should be in production" assert "send_message_to_agent_async" not in result, "Local-only tools should not be in production" else: # Non-production environment should include all multi-agent tools assert result == set(MULTI_AGENT_TOOLS), "Non-production should include all multi-agent tools" assert set(LOCAL_ONLY_MULTI_AGENT_TOOLS).issubset(result), "Non-production should include local-only tools" # Verify specific tools assert "send_message_to_agent_and_wait_for_reply" in result, "All multi-agent tools should be in non-production" assert "send_message_to_agents_matching_tags" in result, "All multi-agent tools should be in non-production" assert "send_message_to_agent_async" in result, "Local-only tools should be in non-production" async def test_upsert_base_tools_excludes_local_only_in_production(server: SyncServer, default_user, set_letta_environment): """Test that upsert_base_tools excludes local-only multi-agent tools in production.""" # Upsert all base tools tools = await server.tool_manager.upsert_base_tools_async(actor=default_user) tool_names = {tool.name for tool in tools} if settings.environment == "prod": # Production environment should exclude local-only multi-agent tools for local_only_tool in LOCAL_ONLY_MULTI_AGENT_TOOLS: assert local_only_tool not in tool_names, f"Local-only tool '{local_only_tool}' should not be upserted in production" # But should include standard multi-agent tools standard_multi_agent_tools = set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS) for standard_tool in standard_multi_agent_tools: assert standard_tool in tool_names, f"Standard multi-agent tool '{standard_tool}' should be upserted in production" else: # Non-production environment should include all multi-agent tools for tool in MULTI_AGENT_TOOLS: assert tool in tool_names, f"Multi-agent tool '{tool}' should be upserted in non-production" async def test_upsert_multi_agent_tools_only(server: SyncServer, default_user, set_letta_environment): """Test that upserting only multi-agent tools respects production filtering.""" from letta.schemas.enums import ToolType # Upsert only multi-agent tools tools = await server.tool_manager.upsert_base_tools_async(actor=default_user, allowed_types={ToolType.LETTA_MULTI_AGENT_CORE}) tool_names = {tool.name for tool in tools} if settings.environment == "prod": # Should only have non-local multi-agent tools expected_tools = set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS) assert tool_names == expected_tools, "Production multi-agent upsert should exclude local-only tools" assert "send_message_to_agent_async" not in tool_names, "Local-only async tool should not be upserted in production" else: # Should have all multi-agent tools assert tool_names == set(MULTI_AGENT_TOOLS), "Non-production multi-agent upsert should include all tools" assert "send_message_to_agent_async" in tool_names, "Local-only async tool should be upserted in non-production" @pytest.mark.asyncio async def test_create_agent_with_default_source(server: SyncServer, default_user, print_tool, default_block): """Test agent creation with include_default_source=True""" memory_blocks = [CreateBlock(label="human", value="TestUser"), CreateBlock(label="persona", value="I am a test assistant")] create_agent_request = CreateAgent( name="test_default_source_agent", agent_type="memgpt_v2_agent", system="test system", memory_blocks=memory_blocks, llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], tool_ids=[print_tool.id], include_default_source=True, # This is the key field we're testing include_base_tools=False, ) # Create the agent created_agent = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) # Verify agent was created assert created_agent is not None assert created_agent.name == "test_default_source_agent" # Verify that a default source was created and attached attached_sources = await server.agent_manager.list_attached_sources_async(agent_id=created_agent.id, actor=default_user) # Should have exactly one source (the default one) assert len(attached_sources) == 1 auto_default_source = attached_sources[0] # Verify the default source properties assert created_agent.name in auto_default_source.name assert auto_default_source.embedding_config.embedding_endpoint_type == "openai" # Test with include_default_source=False create_agent_request_no_source = CreateAgent( name="test_no_default_source_agent", agent_type="memgpt_v2_agent", system="test system", memory_blocks=memory_blocks, llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], tool_ids=[print_tool.id], include_default_source=False, # Explicitly set to False include_base_tools=False, ) created_agent_no_source = await server.agent_manager.create_agent_async( create_agent_request_no_source, actor=default_user, ) # Verify no sources are attached attached_sources_no_source = await server.agent_manager.list_attached_sources_async( agent_id=created_agent_no_source.id, actor=default_user ) assert len(attached_sources_no_source) == 0 # Clean up await server.agent_manager.delete_agent_async(created_agent.id, default_user) await server.agent_manager.delete_agent_async(created_agent_no_source.id, default_user) async def test_get_context_window_basic( server: SyncServer, comprehensive_test_agent_fixture, default_user, default_file, set_letta_environment ): # Test agent creation created_agent, create_agent_request = comprehensive_test_agent_fixture # Attach a file assoc, closed_files = await server.file_agent_manager.attach_file( agent_id=created_agent.id, file_id=default_file.id, file_name=default_file.file_name, source_id=default_file.source_id, actor=default_user, visible_content="hello", max_files_open=created_agent.max_files_open, ) # Get context window and check for basic appearances context_window_overview = await server.agent_manager.get_context_window(agent_id=created_agent.id, actor=default_user) validate_context_window_overview(created_agent, context_window_overview, assoc) # Test deleting the agent await server.agent_manager.delete_agent_async(created_agent.id, default_user) list_agents = await server.agent_manager.list_agents_async(actor=default_user) assert len(list_agents) == 0 @pytest.mark.asyncio async def test_create_agent_passed_in_initial_messages(server: SyncServer, default_user, default_block): memory_blocks = [CreateBlock(label="human", value="BananaBoy"), CreateBlock(label="persona", value="I am a helpful assistant")] create_agent_request = CreateAgent( agent_type="memgpt_v2_agent", system="test system", memory_blocks=memory_blocks, llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], tags=["a", "b"], description="test_description", initial_message_sequence=[MessageCreate(role=MessageRole.user, content="hello world")], include_base_tools=False, ) agent_state = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) assert await server.message_manager.size_async(agent_id=agent_state.id, actor=default_user) == 2 init_messages = await server.message_manager.get_messages_by_ids_async(message_ids=agent_state.message_ids, actor=default_user) # Check that the system appears in the first initial message assert create_agent_request.system in init_messages[0].content[0].text assert create_agent_request.memory_blocks[0].value in init_messages[0].content[0].text # Check that the second message is the passed in initial message seq assert create_agent_request.initial_message_sequence[0].role == init_messages[1].role assert create_agent_request.initial_message_sequence[0].content in init_messages[1].content[0].text @pytest.mark.asyncio async def test_create_agent_default_initial_message(server: SyncServer, default_user, default_block): memory_blocks = [CreateBlock(label="human", value="BananaBoy"), CreateBlock(label="persona", value="I am a helpful assistant")] create_agent_request = CreateAgent( agent_type="memgpt_v2_agent", system="test system", memory_blocks=memory_blocks, llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], tags=["a", "b"], description="test_description", include_base_tools=False, ) agent_state = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) assert await server.message_manager.size_async(agent_id=agent_state.id, actor=default_user) == 4 init_messages = await server.message_manager.get_messages_by_ids_async(message_ids=agent_state.message_ids, actor=default_user) # Check that the system appears in the first initial message assert create_agent_request.system in init_messages[0].content[0].text assert create_agent_request.memory_blocks[0].value in init_messages[0].content[0].text @pytest.mark.asyncio async def test_create_agent_with_json_in_system_message(server: SyncServer, default_user, default_block): system_prompt = ( "You are an expert teaching agent with encyclopedic knowledge. " "When you receive a topic, query the external database for more " "information. Format the queries as a JSON list of queries making " "sure to include your reasoning for that query, e.g. " "{'query1' : 'reason1', 'query2' : 'reason2'}" ) create_agent_request = CreateAgent( agent_type="memgpt_v2_agent", system=system_prompt, llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], tags=["a", "b"], description="test_description", include_base_tools=False, ) agent_state = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) assert agent_state is not None system_message_id = agent_state.message_ids[0] system_message = await server.message_manager.get_message_by_id_async(message_id=system_message_id, actor=default_user) assert system_prompt in system_message.content[0].text assert default_block.value in system_message.content[0].text await server.agent_manager.delete_agent_async(agent_id=agent_state.id, actor=default_user) async def test_update_agent(server: SyncServer, comprehensive_test_agent_fixture, other_tool, other_source, other_block, default_user): agent, _ = comprehensive_test_agent_fixture update_agent_request = UpdateAgent( name="train_agent", description="train description", tool_ids=[other_tool.id], source_ids=[other_source.id], block_ids=[other_block.id], tool_rules=[InitToolRule(tool_name=other_tool.name)], tags=["c", "d"], system="train system", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(model_name="letta"), message_ids=[f"message-{uuid.uuid4()}", f"message-{uuid.uuid4()}"], metadata={"train_key": "train_value"}, tool_exec_environment_variables={"test_env_var_key_a": "a", "new_tool_exec_key": "n"}, message_buffer_autoclear=False, ) last_updated_timestamp = agent.updated_at updated_agent = await server.agent_manager.update_agent_async(agent.id, update_agent_request, actor=default_user) comprehensive_agent_checks(updated_agent, update_agent_request, actor=default_user) assert updated_agent.message_ids == update_agent_request.message_ids assert updated_agent.updated_at > last_updated_timestamp @pytest.mark.asyncio async def test_create_agent_with_compaction_settings(server: SyncServer, default_user, default_block): """Test that agents can be created with custom compaction_settings""" # Upsert base tools await server.tool_manager.upsert_base_tools_async(actor=default_user) # Create custom compaction settings llm_config = LLMConfig.default_config("gpt-4o-mini") model_settings = llm_config._to_model_settings() compaction_settings = CompactionSettings( model="openai/gpt-4o-mini", model_settings=model_settings, prompt="Custom summarization prompt", clip_chars=1500, mode="all", sliding_window_percentage=0.5, ) # Create agent with compaction settings create_agent_request = CreateAgent( name="test_compaction_agent", agent_type="memgpt_v2_agent", system="test system", llm_config=llm_config, embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], include_base_tools=True, compaction_settings=compaction_settings, ) created_agent = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) # Verify compaction settings were stored correctly assert created_agent.compaction_settings is not None assert created_agent.compaction_settings.mode == "all" assert created_agent.compaction_settings.clip_chars == 1500 assert created_agent.compaction_settings.sliding_window_percentage == 0.5 assert created_agent.compaction_settings.prompt == "Custom summarization prompt" # Clean up await server.agent_manager.delete_agent_async(agent_id=created_agent.id, actor=default_user) @pytest.mark.asyncio async def test_update_agent_compaction_settings(server: SyncServer, comprehensive_test_agent_fixture, default_user): """Test that an agent's compaction_settings can be updated""" agent, _ = comprehensive_test_agent_fixture # Verify initial state (should be None or default) assert agent.compaction_settings is None # Create new compaction settings llm_config = LLMConfig.default_config("gpt-4o-mini") model_settings = llm_config._to_model_settings() new_compaction_settings = CompactionSettings( model="openai/gpt-4o-mini", model_settings=model_settings, prompt="Updated summarization prompt", prompt_acknowledgement=False, clip_chars=3000, mode="sliding_window", sliding_window_percentage=0.4, ) # Update agent with compaction settings update_agent_request = UpdateAgent( compaction_settings=new_compaction_settings, ) updated_agent = await server.agent_manager.update_agent_async(agent.id, update_agent_request, actor=default_user) # Verify compaction settings were updated correctly assert updated_agent.compaction_settings is not None assert updated_agent.compaction_settings.mode == "sliding_window" assert updated_agent.compaction_settings.clip_chars == 3000 assert updated_agent.compaction_settings.sliding_window_percentage == 0.4 assert updated_agent.compaction_settings.prompt == "Updated summarization prompt" assert updated_agent.compaction_settings.prompt_acknowledgement == False @pytest.mark.asyncio async def test_agent_file_defaults_based_on_context_window(server: SyncServer, default_user, default_block): """Test that file-related defaults are set based on the model's context window size""" # test with small context window model (8k) llm_config_small = LLMConfig.default_config("gpt-4o-mini") llm_config_small.context_window = 8000 create_agent_request = CreateAgent( name="test_agent_small_context", agent_type="memgpt_v2_agent", llm_config=llm_config_small, embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], include_base_tools=False, ) agent_state = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) assert agent_state.max_files_open == 3 assert ( agent_state.per_file_view_window_char_limit == calculate_file_defaults_based_on_context_window(llm_config_small.context_window)[1] ) await server.agent_manager.delete_agent_async(agent_id=agent_state.id, actor=default_user) # test with medium context window model (32k) llm_config_medium = LLMConfig.default_config("gpt-4o-mini") llm_config_medium.context_window = 32000 create_agent_request = CreateAgent( name="test_agent_medium_context", agent_type="memgpt_v2_agent", llm_config=llm_config_medium, embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], include_base_tools=False, ) agent_state = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) assert agent_state.max_files_open == 5 assert ( agent_state.per_file_view_window_char_limit == calculate_file_defaults_based_on_context_window(llm_config_medium.context_window)[1] ) await server.agent_manager.delete_agent_async(agent_id=agent_state.id, actor=default_user) # test with large context window model (128k) llm_config_large = LLMConfig.default_config("gpt-4o-mini") llm_config_large.context_window = 128000 create_agent_request = CreateAgent( name="test_agent_large_context", agent_type="memgpt_v2_agent", llm_config=llm_config_large, embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], include_base_tools=False, ) agent_state = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) assert agent_state.max_files_open == 10 assert ( agent_state.per_file_view_window_char_limit == calculate_file_defaults_based_on_context_window(llm_config_large.context_window)[1] ) await server.agent_manager.delete_agent_async(agent_id=agent_state.id, actor=default_user) @pytest.mark.asyncio async def test_agent_file_defaults_explicit_values(server: SyncServer, default_user, default_block): """Test that explicitly set file-related values are respected""" llm_config_explicit = LLMConfig.default_config("gpt-4o-mini") llm_config_explicit.context_window = 32000 # would normally get defaults of 5 and 30k create_agent_request = CreateAgent( name="test_agent_explicit_values", agent_type="memgpt_v2_agent", llm_config=llm_config_explicit, embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], include_base_tools=False, max_files_open=20, # explicit value per_file_view_window_char_limit=500_000, # explicit value ) agent_state = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) # verify explicit values are used instead of defaults assert agent_state.max_files_open == 20 assert agent_state.per_file_view_window_char_limit == 500_000 await server.agent_manager.delete_agent_async(agent_id=agent_state.id, actor=default_user) @pytest.mark.asyncio async def test_update_agent_file_fields(server: SyncServer, comprehensive_test_agent_fixture, default_user): """Test updating file-related fields on an existing agent""" agent, _ = comprehensive_test_agent_fixture # update file-related fields update_request = UpdateAgent( max_files_open=15, per_file_view_window_char_limit=150_000, ) updated_agent = await server.agent_manager.update_agent_async(agent.id, update_request, actor=default_user) assert updated_agent.max_files_open == 15 assert updated_agent.per_file_view_window_char_limit == 150_000 @pytest.mark.asyncio async def test_update_agent_last_stop_reason(server: SyncServer, comprehensive_test_agent_fixture, default_user): """Test updating last_stop_reason field on an existing agent""" agent, _ = comprehensive_test_agent_fixture assert agent.last_stop_reason is None # Update with end_turn stop reason update_request = UpdateAgent( last_stop_reason=StopReasonType.end_turn, last_run_completion=datetime.now(timezone.utc), last_run_duration_ms=1500, ) updated_agent = await server.agent_manager.update_agent_async(agent.id, update_request, actor=default_user) assert updated_agent.last_stop_reason == StopReasonType.end_turn assert updated_agent.last_run_completion is not None assert updated_agent.last_run_duration_ms == 1500 # Update with error stop reason update_request = UpdateAgent( last_stop_reason=StopReasonType.error, last_run_completion=datetime.now(timezone.utc), last_run_duration_ms=2500, ) updated_agent = await server.agent_manager.update_agent_async(agent.id, update_request, actor=default_user) assert updated_agent.last_stop_reason == StopReasonType.error assert updated_agent.last_run_duration_ms == 2500 # Update with requires_approval stop reason update_request = UpdateAgent( last_stop_reason=StopReasonType.requires_approval, ) updated_agent = await server.agent_manager.update_agent_async(agent.id, update_request, actor=default_user) assert updated_agent.last_stop_reason == StopReasonType.requires_approval # ====================================================================================================================== # AgentManager Tests - Listing # ====================================================================================================================== @pytest.mark.asyncio async def test_list_agents_select_fields_empty(server: SyncServer, comprehensive_test_agent_fixture, default_user): # Create an agent using the comprehensive fixture. created_agent, create_agent_request = comprehensive_test_agent_fixture # List agents using an empty list for select_fields. agents = await server.agent_manager.list_agents_async(actor=default_user, include_relationships=[]) # Assert that the agent is returned and basic fields are present. assert len(agents) >= 1 agent = agents[0] assert agent.id is not None assert agent.name is not None # Assert no relationships were loaded assert len(agent.tools) == 0 assert len(agent.tags) == 0 @pytest.mark.asyncio async def test_list_agents_select_fields_none(server: SyncServer, comprehensive_test_agent_fixture, default_user): # Create an agent using the comprehensive fixture. created_agent, create_agent_request = comprehensive_test_agent_fixture # List agents using an empty list for select_fields. agents = await server.agent_manager.list_agents_async(actor=default_user, include_relationships=None) # Assert that the agent is returned and basic fields are present. assert len(agents) >= 1 agent = agents[0] assert agent.id is not None assert agent.name is not None # Assert no relationships were loaded assert len(agent.tools) > 0 assert len(agent.tags) > 0 @pytest.mark.asyncio async def test_list_agents_select_fields_specific(server: SyncServer, comprehensive_test_agent_fixture, default_user): created_agent, create_agent_request = comprehensive_test_agent_fixture # Choose a subset of valid relationship fields. valid_fields = ["tools", "tags"] agents = await server.agent_manager.list_agents_async(actor=default_user, include_relationships=valid_fields) assert len(agents) >= 1 agent = agents[0] # Depending on your to_pydantic() implementation, # verify that the fields exist in the returned pydantic model. # (Note: These assertions may require that your CreateAgent fixture sets up these relationships.) assert agent.tools assert sorted(agent.tags) == ["a", "b"] assert not agent.memory.blocks @pytest.mark.asyncio async def test_list_agents_select_fields_invalid(server: SyncServer, comprehensive_test_agent_fixture, default_user): created_agent, create_agent_request = comprehensive_test_agent_fixture # Provide field names that are not recognized. invalid_fields = ["foobar", "nonexistent_field"] # The expectation is that these fields are simply ignored. agents = await server.agent_manager.list_agents_async(actor=default_user, include_relationships=invalid_fields) assert len(agents) >= 1 agent = agents[0] # Verify that standard fields are still present.c assert agent.id is not None assert agent.name is not None @pytest.mark.asyncio async def test_list_agents_select_fields_duplicates(server: SyncServer, comprehensive_test_agent_fixture, default_user): created_agent, create_agent_request = comprehensive_test_agent_fixture # Provide duplicate valid field names. duplicate_fields = ["tools", "tools", "tags", "tags"] agents = await server.agent_manager.list_agents_async(actor=default_user, include_relationships=duplicate_fields) assert len(agents) >= 1 agent = agents[0] # Verify that the agent pydantic representation includes the relationships. # Even if duplicates were provided, the query should not break. assert isinstance(agent.tools, list) assert isinstance(agent.tags, list) @pytest.mark.asyncio async def test_list_agents_select_fields_mixed(server: SyncServer, comprehensive_test_agent_fixture, default_user): created_agent, create_agent_request = comprehensive_test_agent_fixture # Mix valid fields with an invalid one. mixed_fields = ["tools", "invalid_field"] agents = await server.agent_manager.list_agents_async(actor=default_user, include_relationships=mixed_fields) assert len(agents) >= 1 agent = agents[0] # Valid fields should be loaded and accessible. assert agent.tools # Since "invalid_field" is not recognized, it should have no adverse effect. # You might optionally check that no extra attribute is created on the pydantic model. assert not hasattr(agent, "invalid_field") @pytest.mark.asyncio async def test_list_agents_ascending(server: SyncServer, default_user): # Create two agents with known names agent1 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_oldest", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, ), actor=default_user, ) if USING_SQLITE: time.sleep(CREATE_DELAY_SQLITE) agent2 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_newest", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, ), actor=default_user, ) agents = await server.agent_manager.list_agents_async(actor=default_user, ascending=True) names = [agent.name for agent in agents] assert names.index("agent_oldest") < names.index("agent_newest") @pytest.mark.asyncio async def test_list_agents_descending(server: SyncServer, default_user): # Create two agents with known names agent1 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_oldest", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, ), actor=default_user, ) if USING_SQLITE: time.sleep(CREATE_DELAY_SQLITE) agent2 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_newest", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, ), actor=default_user, ) agents = await server.agent_manager.list_agents_async(actor=default_user, ascending=False) names = [agent.name for agent in agents] assert names.index("agent_newest") < names.index("agent_oldest") @pytest.mark.asyncio async def test_list_agents_by_last_stop_reason(server: SyncServer, default_user): # Create agent with requires_approval stop reason agent1 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_requires_approval", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, ), actor=default_user, ) await server.agent_manager.update_agent_async( agent_id=agent1.id, agent_update=UpdateAgent(last_stop_reason=StopReasonType.requires_approval), actor=default_user, ) # Create agent with error stop reason agent2 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_error", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, ), actor=default_user, ) await server.agent_manager.update_agent_async( agent_id=agent2.id, agent_update=UpdateAgent(last_stop_reason=StopReasonType.error), actor=default_user, ) # Create agent with no stop reason agent3 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_no_stop_reason", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, ), actor=default_user, ) # Filter by requires_approval approval_agents = await server.agent_manager.list_agents_async( actor=default_user, last_stop_reason=StopReasonType.requires_approval.value ) approval_names = {agent.name for agent in approval_agents} assert approval_names == {"agent_requires_approval"} # Filter by error error_agents = await server.agent_manager.list_agents_async(actor=default_user, last_stop_reason=StopReasonType.error.value) error_names = {agent.name for agent in error_agents} assert error_names == {"agent_error"} # No filter - should return all agents all_agents = await server.agent_manager.list_agents_async(actor=default_user) all_names = {agent.name for agent in all_agents} assert {"agent_requires_approval", "agent_error", "agent_no_stop_reason"}.issubset(all_names) @pytest.mark.asyncio async def test_count_agents_with_filters(server: SyncServer, default_user): """Test count_agents_async with various filters""" # Create agents with different attributes agent1 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_requires_approval", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, tags=["inbox", "test"], ), actor=default_user, ) await server.agent_manager.update_agent_async( agent_id=agent1.id, agent_update=UpdateAgent(last_stop_reason=StopReasonType.requires_approval), actor=default_user, ) agent2 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_error", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, tags=["error", "test"], ), actor=default_user, ) await server.agent_manager.update_agent_async( agent_id=agent2.id, agent_update=UpdateAgent(last_stop_reason=StopReasonType.error), actor=default_user, ) agent3 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_completed", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, tags=["completed"], ), actor=default_user, ) await server.agent_manager.update_agent_async( agent_id=agent3.id, agent_update=UpdateAgent(last_stop_reason=StopReasonType.end_turn), actor=default_user, ) agent4 = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name="agent_no_stop_reason", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), memory_blocks=[], include_base_tools=False, tags=["test"], ), actor=default_user, ) # Test count with no filters - should return total count total_count = await server.agent_manager.count_agents_async(actor=default_user) assert total_count >= 4 # Test count by last_stop_reason - requires_approval (inbox use case) approval_count = await server.agent_manager.count_agents_async( actor=default_user, last_stop_reason=StopReasonType.requires_approval.value ) assert approval_count == 1 # Test count by last_stop_reason - error error_count = await server.agent_manager.count_agents_async(actor=default_user, last_stop_reason=StopReasonType.error.value) assert error_count == 1 # Test count by last_stop_reason - end_turn completed_count = await server.agent_manager.count_agents_async(actor=default_user, last_stop_reason=StopReasonType.end_turn.value) assert completed_count == 1 # Test count by tags test_tag_count = await server.agent_manager.count_agents_async(actor=default_user, tags=["test"]) assert test_tag_count == 3 # Test count by tags with match_all_tags inbox_test_count = await server.agent_manager.count_agents_async(actor=default_user, tags=["inbox", "test"], match_all_tags=True) assert inbox_test_count == 1 # Test count by name name_count = await server.agent_manager.count_agents_async(actor=default_user, name="agent_requires_approval") assert name_count == 1 # Test count by query_text query_count = await server.agent_manager.count_agents_async(actor=default_user, query_text="error") assert query_count >= 1 # Test combined filters: last_stop_reason + tags combined_count = await server.agent_manager.count_agents_async( actor=default_user, last_stop_reason=StopReasonType.requires_approval.value, tags=["inbox"] ) assert combined_count == 1 @pytest.mark.asyncio async def test_list_agents_ordering_and_pagination(server: SyncServer, default_user): names = ["alpha_agent", "beta_agent", "gamma_agent"] created_agents = [] # Create agents in known order for name in names: agent = await server.agent_manager.create_agent_async( agent_create=CreateAgent( name=name, agent_type="memgpt_v2_agent", 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_agents.append(agent) if USING_SQLITE: time.sleep(CREATE_DELAY_SQLITE) agent_ids = {agent.name: agent.id for agent in created_agents} # Ascending (oldest to newest) agents_asc = await server.agent_manager.list_agents_async(actor=default_user, ascending=True) asc_names = [agent.name for agent in agents_asc] assert asc_names.index("alpha_agent") < asc_names.index("beta_agent") < asc_names.index("gamma_agent") # Descending (newest to oldest) agents_desc = await server.agent_manager.list_agents_async(actor=default_user, ascending=False) desc_names = [agent.name for agent in agents_desc] assert desc_names.index("gamma_agent") < desc_names.index("beta_agent") < desc_names.index("alpha_agent") # After: Get agents after alpha_agent in ascending order (should exclude alpha) after_alpha = await server.agent_manager.list_agents_async(actor=default_user, after=agent_ids["alpha_agent"], ascending=True) after_names = [a.name for a in after_alpha] assert "alpha_agent" not in after_names assert "beta_agent" in after_names assert "gamma_agent" in after_names assert after_names == ["beta_agent", "gamma_agent"] # Before: Get agents before gamma_agent in ascending order (should exclude gamma) before_gamma = await server.agent_manager.list_agents_async(actor=default_user, before=agent_ids["gamma_agent"], ascending=True) before_names = [a.name for a in before_gamma] assert "gamma_agent" not in before_names assert "alpha_agent" in before_names assert "beta_agent" in before_names assert before_names == ["alpha_agent", "beta_agent"] # After: Get agents after gamma_agent in descending order (should exclude gamma, return beta then alpha) after_gamma_desc = await server.agent_manager.list_agents_async(actor=default_user, after=agent_ids["gamma_agent"], ascending=False) after_names_desc = [a.name for a in after_gamma_desc] assert after_names_desc == ["beta_agent", "alpha_agent"] # Before: Get agents before alpha_agent in descending order (should exclude alpha) before_alpha_desc = await server.agent_manager.list_agents_async(actor=default_user, before=agent_ids["alpha_agent"], ascending=False) before_names_desc = [a.name for a in before_alpha_desc] assert before_names_desc == ["gamma_agent", "beta_agent"] # ====================================================================================================================== # AgentManager Tests - Environment Variable Encryption # ====================================================================================================================== @pytest.fixture def encryption_key(): """Fixture to ensure encryption key is set for tests.""" original_key = settings.encryption_key # Set a test encryption key if not already set if not settings.encryption_key: settings.encryption_key = "test-encryption-key-32-bytes!!" yield settings.encryption_key # Restore original settings.encryption_key = original_key @pytest.mark.asyncio async def test_agent_environment_variables_encrypt_on_create(server: SyncServer, default_user, encryption_key): """Test that creating an agent with secrets encrypts the values in the database.""" from letta.orm.sandbox_config import AgentEnvironmentVariable as AgentEnvironmentVariableModel from letta.schemas.secret import Secret # Create agent with secrets agent_create = CreateAgent( name="test-agent-with-secrets", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=DEFAULT_EMBEDDING_CONFIG, include_base_tools=False, secrets={ "API_KEY": "sk-test-secret-12345", "DATABASE_URL": "postgres://user:pass@localhost/db", }, ) created_agent = await server.agent_manager.create_agent_async(agent_create, actor=default_user) # Verify agent has secrets assert created_agent.secrets is not None assert len(created_agent.secrets) == 2 # Verify secrets are AgentEnvironmentVariable objects with Secret fields for secret_obj in created_agent.secrets: assert secret_obj.key in ["API_KEY", "DATABASE_URL"] assert secret_obj.value_enc is not None assert isinstance(secret_obj.value_enc, Secret) # Verify values are encrypted in the database async with db_registry.async_session() as session: env_vars = await session.execute( select(AgentEnvironmentVariableModel).where(AgentEnvironmentVariableModel.agent_id == created_agent.id) ) env_var_list = list(env_vars.scalars().all()) assert len(env_var_list) == 2 for env_var in env_var_list: # Check that value_enc is not None and is encrypted assert env_var.value_enc is not None assert isinstance(env_var.value_enc, str) # Decrypt and verify decrypted = Secret.from_encrypted(env_var.value_enc).get_plaintext() if env_var.key == "API_KEY": assert decrypted == "sk-test-secret-12345" elif env_var.key == "DATABASE_URL": assert decrypted == "postgres://user:pass@localhost/db" @pytest.mark.asyncio async def test_agent_environment_variables_decrypt_on_read(server: SyncServer, default_user, encryption_key): """Test that reading an agent deserializes secrets correctly to AgentEnvironmentVariable objects.""" from letta.schemas.environment_variables import AgentEnvironmentVariable from letta.schemas.secret import Secret # Create agent with secrets agent_create = CreateAgent( name="test-agent-read-secrets", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=DEFAULT_EMBEDDING_CONFIG, include_base_tools=False, secrets={ "TEST_KEY": "test-value-67890", }, ) created_agent = await server.agent_manager.create_agent_async(agent_create, actor=default_user) agent_id = created_agent.id # Read the agent back retrieved_agent = await server.agent_manager.get_agent_by_id_async(agent_id=agent_id, actor=default_user) # Verify secrets are properly deserialized assert retrieved_agent.secrets is not None assert len(retrieved_agent.secrets) == 1 secret_obj = retrieved_agent.secrets[0] assert isinstance(secret_obj, AgentEnvironmentVariable) assert secret_obj.key == "TEST_KEY" assert secret_obj.value == "test-value-67890" # Verify value_enc is a Secret object (not a string) assert secret_obj.value_enc is not None assert isinstance(secret_obj.value_enc, Secret) # Verify we can decrypt through the Secret object decrypted = secret_obj.value_enc.get_plaintext() assert decrypted == "test-value-67890" # Verify direct value_enc access works assert isinstance(secret_obj.value_enc, Secret) assert secret_obj.value_enc.get_plaintext() == "test-value-67890" @pytest.mark.asyncio async def test_agent_environment_variables_update_encryption(server: SyncServer, default_user, encryption_key): """Test that updating agent secrets encrypts new values.""" from letta.orm.sandbox_config import AgentEnvironmentVariable as AgentEnvironmentVariableModel from letta.schemas.secret import Secret # Create agent with initial secrets agent_create = CreateAgent( name="test-agent-update-secrets", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=DEFAULT_EMBEDDING_CONFIG, include_base_tools=False, secrets={ "INITIAL_KEY": "initial-value", }, ) created_agent = await server.agent_manager.create_agent_async(agent_create, actor=default_user) agent_id = created_agent.id # Update with new secrets agent_update = UpdateAgent( secrets={ "UPDATED_KEY": "updated-value-abc", "NEW_KEY": "new-value-xyz", }, ) updated_agent = await server.agent_manager.update_agent_async(agent_id=agent_id, agent_update=agent_update, actor=default_user) # Verify updated secrets assert updated_agent.secrets is not None assert len(updated_agent.secrets) == 2 # Verify in database async with db_registry.async_session() as session: env_vars = await session.execute(select(AgentEnvironmentVariableModel).where(AgentEnvironmentVariableModel.agent_id == agent_id)) env_var_list = list(env_vars.scalars().all()) assert len(env_var_list) == 2 for env_var in env_var_list: assert env_var.value_enc is not None # Decrypt and verify decrypted = Secret.from_encrypted(env_var.value_enc).get_plaintext() if env_var.key == "UPDATED_KEY": assert decrypted == "updated-value-abc" elif env_var.key == "NEW_KEY": assert decrypted == "new-value-xyz" else: pytest.fail(f"Unexpected key: {env_var.key}") @pytest.mark.asyncio async def test_agent_secrets_clear_with_empty_dict(server: SyncServer, default_user, encryption_key): """Test that updating agent secrets with empty dict clears all secrets.""" from letta.orm.sandbox_config import AgentEnvironmentVariable as AgentEnvironmentVariableModel # Create agent with initial secrets agent_create = CreateAgent( name="test-agent-clear-secrets", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=DEFAULT_EMBEDDING_CONFIG, include_base_tools=False, secrets={ "SECRET_KEY_1": "secret-value-1", "SECRET_KEY_2": "secret-value-2", }, ) created_agent = await server.agent_manager.create_agent_async(agent_create, actor=default_user) agent_id = created_agent.id # Verify secrets were created assert created_agent.secrets is not None assert len(created_agent.secrets) == 2 # Update with empty dict to clear all secrets agent_update = UpdateAgent(secrets={}) updated_agent = await server.agent_manager.update_agent_async(agent_id=agent_id, agent_update=agent_update, actor=default_user) # Verify secrets are cleared assert updated_agent.secrets is not None assert len(updated_agent.secrets) == 0 # Verify in database async with db_registry.async_session() as session: env_vars = await session.execute(select(AgentEnvironmentVariableModel).where(AgentEnvironmentVariableModel.agent_id == agent_id)) env_var_list = list(env_vars.scalars().all()) assert len(env_var_list) == 0 @pytest.mark.asyncio async def test_agent_state_schema_unchanged(server: SyncServer): """ Test that the AgentState pydantic schema structure has not changed. This test validates all fields including nested pydantic objects to ensure the schema remains stable across changes. """ from letta.schemas.agent import AgentState, AgentType from letta.schemas.block import Block from letta.schemas.embedding_config import EmbeddingConfig from letta.schemas.environment_variables import AgentEnvironmentVariable from letta.schemas.group import Group from letta.schemas.letta_message import ApprovalRequestMessage from letta.schemas.llm_config import LLMConfig from letta.schemas.memory import Memory from letta.schemas.model import ModelSettingsUnion from letta.schemas.response_format import ResponseFormatUnion from letta.schemas.source import Source from letta.schemas.tool import Tool from letta.schemas.tool_rule import ToolRule from letta.services.summarizer.summarizer_config import CompactionSettings # Define the expected schema structure expected_schema = { # Core identification "id": str, "name": str, # Tool rules "tool_rules": (list, type(None)), # In-context memory "message_ids": (list, type(None)), # System prompt "system": str, # Agent configuration "agent_type": AgentType, # LLM information "llm_config": LLMConfig, "compaction_settings": CompactionSettings, "model": str, "embedding": str, "embedding_config": EmbeddingConfig, "model_settings": (ModelSettingsUnion, type(None)), "response_format": (ResponseFormatUnion, type(None)), # State fields "description": (str, type(None)), "metadata": (dict, type(None)), # Memory and tools "memory": Memory, # deprecated "blocks": list, "tools": list, "sources": list, "tags": list, "tool_exec_environment_variables": list, # deprecated "secrets": list, # Project and template fields "project_id": (str, type(None)), "template_id": (str, type(None)), "base_template_id": (str, type(None)), "deployment_id": (str, type(None)), "entity_id": (str, type(None)), "identity_ids": list, "identities": list, "pending_approval": (ApprovalRequestMessage, type(None)), # Advanced configuration "message_buffer_autoclear": bool, "enable_sleeptime": (bool, type(None)), # Multi-agent "multi_agent_group": (Group, type(None)), # deprecated "managed_group": (Group, type(None)), # Run metrics "last_run_completion": (datetime, type(None)), "last_run_duration_ms": (int, type(None)), "last_stop_reason": (StopReasonType, type(None)), # Timezone "timezone": (str, type(None)), # File controls "max_files_open": (int, type(None)), "per_file_view_window_char_limit": (int, type(None)), # Indexing controls "hidden": (bool, type(None)), # Metadata fields (from OrmMetadataBase) "created_by_id": (str, type(None)), "last_updated_by_id": (str, type(None)), "created_at": (datetime, type(None)), "updated_at": (datetime, type(None)), } # Get the actual schema fields from AgentState agent_state_fields = AgentState.model_fields actual_field_names = set(agent_state_fields.keys()) expected_field_names = set(expected_schema.keys()) # Check for added fields added_fields = actual_field_names - expected_field_names if added_fields: pytest.fail( f"New fields detected in AgentState schema: {sorted(added_fields)}. " "This test must be updated to include these fields, and the schema change must be intentional." ) # Check for removed fields removed_fields = expected_field_names - actual_field_names if removed_fields: pytest.fail( f"Fields removed from AgentState schema: {sorted(removed_fields)}. " "This test must be updated to remove these fields, and the schema change must be intentional." ) # Validate field types import typing for field_name, expected_type in expected_schema.items(): field = agent_state_fields[field_name] annotation = field.annotation # Helper function to check if annotation matches expected type def check_type_match(annotation, expected): origin = typing.get_origin(annotation) args = typing.get_args(annotation) # Direct match if annotation == expected: return True # Handle list type (List[X] should match list) if expected is list and origin is list: return True # Handle dict type (Dict[X, Y] should match dict) if expected is dict and origin is dict: return True # Handle Optional types if origin is typing.Union: # Check if expected type is in the union if expected in args: return True # Handle list case within Union (e.g., Union[List[X], None]) if expected is list: for arg in args: if typing.get_origin(arg) is list: return True # Handle dict case within Union if expected is dict: for arg in args: if typing.get_origin(arg) is dict: return True # Handle Annotated types within Union (e.g., Union[Annotated[...], None]) # This checks if any of the union args is an Annotated type that matches expected for arg in args: if typing.get_origin(arg) is typing.Annotated: # For Annotated types, compare the first argument (the actual type) annotated_args = typing.get_args(arg) if annotated_args and annotated_args[0] == expected: return True return False # Handle tuple of expected types (Optional) if isinstance(expected_type, tuple): valid = any(check_type_match(annotation, exp_t) for exp_t in expected_type) if not valid: pytest.fail( f"Field '{field_name}' type changed. Expected one of {expected_type}, " f"but got {annotation}. Schema changes must be intentional." ) else: # Single expected type valid = check_type_match(annotation, expected_type) if not valid: pytest.fail( f"Field '{field_name}' type changed. Expected {expected_type}, " f"but got {annotation}. Schema changes must be intentional." ) # Validate nested object schemas # Memory schema memory_fields = Memory.model_fields expected_memory_fields = {"agent_type", "git_enabled", "blocks", "file_blocks", "prompt_template"} actual_memory_fields = set(memory_fields.keys()) if actual_memory_fields != expected_memory_fields: pytest.fail( f"Memory schema changed. Expected fields: {expected_memory_fields}, " f"Got: {actual_memory_fields}. Schema changes must be intentional." ) # Block schema block_fields = Block.model_fields expected_block_fields = { "id", "value", "limit", "project_id", "template_name", "is_template", "template_id", "base_template_id", "deployment_id", "entity_id", "preserve_on_migration", "label", "read_only", "description", "metadata", "hidden", "created_by_id", "last_updated_by_id", "tags", } actual_block_fields = set(block_fields.keys()) if actual_block_fields != expected_block_fields: pytest.fail( f"Block schema changed. Expected fields: {expected_block_fields}, " f"Got: {actual_block_fields}. Schema changes must be intentional." ) # Tool schema tool_fields = Tool.model_fields expected_tool_fields = { "id", "tool_type", "description", "source_type", "name", "tags", "source_code", "json_schema", "args_json_schema", "return_char_limit", "pip_requirements", "npm_requirements", "default_requires_approval", "enable_parallel_execution", "created_by_id", "last_updated_by_id", "metadata_", "project_id", } actual_tool_fields = set(tool_fields.keys()) if actual_tool_fields != expected_tool_fields: pytest.fail( f"Tool schema changed. Expected fields: {expected_tool_fields}, Got: {actual_tool_fields}. Schema changes must be intentional." ) # Source schema source_fields = Source.model_fields expected_source_fields = { "id", "name", "description", "instructions", "metadata", "embedding_config", "organization_id", "vector_db_provider", "created_by_id", "last_updated_by_id", "created_at", "updated_at", } actual_source_fields = set(source_fields.keys()) if actual_source_fields != expected_source_fields: pytest.fail( f"Source schema changed. Expected fields: {expected_source_fields}, " f"Got: {actual_source_fields}. Schema changes must be intentional." ) # LLMConfig schema llm_config_fields = LLMConfig.model_fields expected_llm_config_fields = { "model", "display_name", "model_endpoint_type", "model_endpoint", "provider_name", "provider_category", "model_wrapper", "context_window", "put_inner_thoughts_in_kwargs", "handle", "temperature", "max_tokens", "enable_reasoner", "reasoning_effort", "effort", "response_format", "max_reasoning_tokens", "frequency_penalty", "compatibility_type", "verbosity", "tier", "parallel_tool_calls", "strict", "return_logprobs", "top_logprobs", "return_token_ids", } actual_llm_config_fields = set(llm_config_fields.keys()) if actual_llm_config_fields != expected_llm_config_fields: pytest.fail( f"LLMConfig schema changed. Expected fields: {expected_llm_config_fields}, " f"Got: {actual_llm_config_fields}. Schema changes must be intentional." ) # EmbeddingConfig schema embedding_config_fields = EmbeddingConfig.model_fields expected_embedding_config_fields = { "embedding_endpoint_type", "embedding_endpoint", "embedding_model", "embedding_dim", "embedding_chunk_size", "handle", "batch_size", "azure_endpoint", "azure_version", "azure_deployment", } actual_embedding_config_fields = set(embedding_config_fields.keys()) if actual_embedding_config_fields != expected_embedding_config_fields: pytest.fail( f"EmbeddingConfig schema changed. Expected fields: {expected_embedding_config_fields}, " f"Got: {actual_embedding_config_fields}. Schema changes must be intentional." ) # AgentEnvironmentVariable schema agent_env_var_fields = AgentEnvironmentVariable.model_fields expected_agent_env_var_fields = { "id", "key", "value", "description", "organization_id", "value_enc", "agent_id", # From OrmMetadataBase "created_by_id", "last_updated_by_id", "created_at", "updated_at", } actual_agent_env_var_fields = set(agent_env_var_fields.keys()) if actual_agent_env_var_fields != expected_agent_env_var_fields: pytest.fail( f"AgentEnvironmentVariable schema changed. Expected fields: {expected_agent_env_var_fields}, " f"Got: {actual_agent_env_var_fields}. Schema changes must be intentional." ) # Group schema group_fields = Group.model_fields expected_group_fields = { "id", "manager_type", "agent_ids", "description", "project_id", "template_id", "base_template_id", "deployment_id", "shared_block_ids", "manager_agent_id", "termination_token", "max_turns", "sleeptime_agent_frequency", "turns_counter", "last_processed_message_id", "max_message_buffer_length", "min_message_buffer_length", "hidden", } actual_group_fields = set(group_fields.keys()) if actual_group_fields != expected_group_fields: pytest.fail( f"Group schema changed. Expected fields: {expected_group_fields}, " f"Got: {actual_group_fields}. Schema changes must be intentional." ) async def test_agent_state_relationship_loads(server: SyncServer, default_user, print_tool, default_block): memory_blocks = [CreateBlock(label="human", value="TestUser"), CreateBlock(label="persona", value="I am a test assistant")] create_agent_request = CreateAgent( name="test_default_source_agent", agent_type="memgpt_v2_agent", system="test system", memory_blocks=memory_blocks, llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], tool_ids=[print_tool.id], include_default_source=True, include_base_tools=False, tags=["test_tag"], ) # Create the agent created_agent = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) # Test legacy default include_relationships agent_state = await server.agent_manager.get_agent_by_id_async( agent_id=created_agent.id, actor=default_user, ) assert agent_state.blocks assert agent_state.sources assert agent_state.tags assert agent_state.tools # Test include_relationships override agent_state = await server.agent_manager.get_agent_by_id_async( agent_id=created_agent.id, actor=default_user, include_relationships=[], ) assert not agent_state.blocks assert not agent_state.sources assert not agent_state.tags assert not agent_state.tools # Test include_relationships override with specific relationships # Note: tags are always loaded alongside memory (needed for git_enabled) agent_state = await server.agent_manager.get_agent_by_id_async( agent_id=created_agent.id, actor=default_user, include_relationships=["memory", "sources"], ) assert agent_state.blocks assert agent_state.sources assert agent_state.tags # tags loaded with memory for git_enabled assert not agent_state.tools # Test include override with specific relationships agent_state = await server.agent_manager.get_agent_by_id_async( agent_id=created_agent.id, actor=default_user, include_relationships=[], include=["agent.blocks", "agent.sources"], ) assert agent_state.blocks assert agent_state.sources assert agent_state.tags # tags loaded with blocks for git_enabled assert not agent_state.tools async def test_create_template_agent_with_files_from_sources(server: SyncServer, default_user, print_tool, default_block): """Test that agents created from templates properly attach files from their sources""" from letta.schemas.file import FileMetadata as PydanticFileMetadata memory_blocks = [CreateBlock(label="human", value="TestUser"), CreateBlock(label="persona", value="I am a test assistant")] # Create a source with files source = await server.source_manager.create_source( source=PydanticSource( name="test_template_source", embedding_config=EmbeddingConfig.default_config(provider="openai"), ), actor=default_user, ) # Create files in the source file1_metadata = PydanticFileMetadata( file_name="template_file_1.txt", organization_id=default_user.organization_id, source_id=source.id, ) file1 = await server.file_manager.create_file(file_metadata=file1_metadata, actor=default_user, text="content for file 1") file2_metadata = PydanticFileMetadata( file_name="template_file_2.txt", organization_id=default_user.organization_id, source_id=source.id, ) file2 = await server.file_manager.create_file(file_metadata=file2_metadata, actor=default_user, text="content for file 2") # Create agent using InternalTemplateAgentCreate with the source create_agent_request = InternalTemplateAgentCreate( name="test_template_agent_with_files", agent_type="memgpt_v2_agent", system="test system", memory_blocks=memory_blocks, llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), block_ids=[default_block.id], tool_ids=[print_tool.id], source_ids=[source.id], # Attach the source with files include_base_tools=False, base_template_id="base_template_123", template_id="template_456", deployment_id="deployment_789", entity_id="entity_012", ) # Create the agent created_agent = await server.agent_manager.create_agent_async( create_agent_request, actor=default_user, ) # Verify agent was created assert created_agent is not None assert created_agent.name == "test_template_agent_with_files" # Verify that the source is attached attached_sources = await server.agent_manager.list_attached_sources_async(agent_id=created_agent.id, actor=default_user) assert len(attached_sources) == 1 assert attached_sources[0].id == source.id # Verify that files from the source are attached to the agent attached_files = await server.file_agent_manager.list_files_for_agent( created_agent.id, per_file_view_window_char_limit=created_agent.per_file_view_window_char_limit, actor=default_user ) # Should have both files attached assert len(attached_files) == 2 attached_file_names = {f.file_name for f in attached_files} assert "template_file_1.txt" in attached_file_names assert "template_file_2.txt" in attached_file_names # Verify files are properly linked to the source for attached_file in attached_files: assert attached_file.source_id == source.id # Clean up await server.agent_manager.delete_agent_async(created_agent.id, default_user) await server.source_manager.delete_source(source.id, default_user)