* feat: add tags support to blocks * fix: add timestamps and org scoping to blocks_tags Addresses PR feedback: 1. Migration: Added timestamps (created_at, updated_at), soft delete (is_deleted), audit fields (_created_by_id, _last_updated_by_id), and organization_id to blocks_tags table for filtering support. Follows SQLite baseline pattern (composite PK of block_id+tag, no separate id column) to avoid insert failures. 2. ORM: Relationship already correct with lazy="raise" to prevent implicit joins and passive_deletes=True for efficient CASCADE deletes. 3. Schema: Changed normalize_tags() from Any to dict for type safety. 4. SQLite: Added blocks_tags to SQLite baseline schema to prevent table-not-found errors. 5. Code: Updated all tag row inserts to include organization_id. 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: add ORM columns and update SQLite baseline for blocks_tags Fixes test failures (CompileError: Unconsumed column names: organization_id): 1. ORM: Added organization_id, timestamps, audit fields to BlocksTags ORM model to match database schema from migrations. 2. SQLite baseline: Added full column set to blocks_tags (organization_id, timestamps, audit fields) to match PostgreSQL schema. 3. Test: Added 'tags' to expected Block schema fields. This ensures SQLite and PostgreSQL have matching schemas and the ORM can consume all columns that the code inserts. 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * revert change to existing alembic migration * fix: remove passive_deletes and SQLite support for blocks_tags 1. Removed passive_deletes=True from Block.tags relationship to match AgentsTags pattern (neither have ondelete CASCADE in DB schema). 2. Removed SQLite branch from _replace_block_pivot_rows_async since blocks_tags table is PostgreSQL-only (migration skips SQLite). 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * api sync --------- Co-authored-by: Letta <noreply@letta.com>
2085 lines
84 KiB
Python
2085 lines
84 KiB
Python
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.agents.letta_agent_v3 import LettaAgentV3
|
|
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
|
|
|
|
# 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 static helper on LettaAgentV3 to derive summarizer llm_config
|
|
summarizer_llm_config = LettaAgentV3._build_summarizer_llm_config(
|
|
agent_llm_config=agent_state.llm_config,
|
|
summarizer_config=agent_state.compaction_settings,
|
|
)
|
|
|
|
# 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=["10", "20"],
|
|
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", "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",
|
|
}
|
|
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
|
|
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 not agent_state.tags
|
|
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 not agent_state.tags
|
|
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)
|