import asyncio import uuid from datetime import datetime from typing import TYPE_CHECKING, Any, List, Optional, Set from sqlalchemy import JSON, Boolean, DateTime, Index, Integer, String, select from sqlalchemy.ext.asyncio import AsyncAttrs, async_object_session from sqlalchemy.orm import Mapped, mapped_column, relationship from letta.orm.block import Block from letta.orm.custom_columns import CompactionSettingsColumn, EmbeddingConfigColumn, LLMConfigColumn, ResponseFormatColumn, ToolRulesColumn from letta.orm.identity import Identity from letta.orm.message import Message as MessageModel from letta.orm.mixins import OrganizationMixin, ProjectMixin, TemplateEntityMixin, TemplateMixin from letta.orm.organization import Organization from letta.orm.sqlalchemy_base import SqlalchemyBase from letta.schemas.agent import AgentState as PydanticAgentState ENCRYPTED_PLACEHOLDER = "" from letta.schemas.embedding_config import EmbeddingConfig from letta.schemas.enums import AgentType from letta.schemas.environment_variables import AgentEnvironmentVariable as PydanticAgentEnvVar from letta.schemas.letta_stop_reason import StopReasonType from letta.schemas.llm_config import LLMConfig from letta.schemas.memory import Memory from letta.schemas.response_format import ResponseFormatUnion from letta.schemas.tool_rule import ToolRule from letta.utils import bounded_gather, calculate_file_defaults_based_on_context_window if TYPE_CHECKING: from letta.orm.agents_tags import AgentsTags from letta.orm.archives_agents import ArchivesAgents from letta.orm.conversation import Conversation from letta.orm.files_agents import FileAgent from letta.orm.identity import Identity from letta.orm.organization import Organization from letta.orm.run import Run from letta.orm.source import Source from letta.orm.tool import Tool from letta.services.summarizer.summarizer_config import CompactionSettings class Agent(SqlalchemyBase, OrganizationMixin, ProjectMixin, TemplateEntityMixin, TemplateMixin, AsyncAttrs): __tablename__ = "agents" __pydantic_model__ = PydanticAgentState __table_args__ = ( Index("ix_agents_created_at", "created_at", "id"), Index("ix_agents_organization_id_deployment_id", "organization_id", "deployment_id"), Index("ix_agents_project_id", "project_id"), ) # agent generates its own id # TODO: We want to migrate all the ORM models to do this, so we will need to move this to the SqlalchemyBase # TODO: Some still rely on the Pydantic object to do this id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"agent-{uuid.uuid4()}") # Descriptor fields agent_type: Mapped[Optional[AgentType]] = mapped_column(String, nullable=True, doc="The type of Agent") name: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="a human-readable identifier for an agent, non-unique.") description: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The description of the agent.") # System prompt system: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The system prompt used by the agent.") # In context memory # TODO: This should be a separate mapping table # This is dangerously flexible with the JSON type message_ids: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True, doc="List of message IDs in in-context memory.") # Response Format response_format: Mapped[Optional[ResponseFormatUnion]] = mapped_column( ResponseFormatColumn, nullable=True, doc="The response format for the agent." ) # Metadata and configs metadata_: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, doc="metadata for the agent.") llm_config: Mapped[Optional[LLMConfig]] = mapped_column( LLMConfigColumn, nullable=True, doc="the LLM backend configuration object for this agent." ) embedding_config: Mapped[Optional[EmbeddingConfig]] = mapped_column( EmbeddingConfigColumn, nullable=True, doc="the embedding configuration object for this agent." ) compaction_settings: Mapped[Optional[dict]] = mapped_column( CompactionSettingsColumn, nullable=True, doc="the compaction settings configuration object for compaction." ) # Tool rules tool_rules: Mapped[Optional[List[ToolRule]]] = mapped_column(ToolRulesColumn, doc="the tool rules for this agent.") # Stateless message_buffer_autoclear: Mapped[bool] = mapped_column( Boolean, doc="If set to True, the agent will not remember previous messages. Not recommended unless you have an advanced use case." ) enable_sleeptime: Mapped[Optional[bool]] = mapped_column( Boolean, doc="If set to True, memory management will move to a background agent thread." ) # Run metrics last_run_completion: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, doc="The timestamp when the agent last completed a run." ) last_run_duration_ms: Mapped[Optional[int]] = mapped_column( Integer, nullable=True, doc="The duration in milliseconds of the agent's last run." ) last_stop_reason: Mapped[Optional[StopReasonType]] = mapped_column( String, nullable=True, doc="The stop reason from the agent's last run." ) # timezone timezone: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The timezone of the agent (for the context window).") # file related controls max_files_open: Mapped[Optional[int]] = mapped_column( Integer, nullable=True, doc="Maximum number of files that can be open at once for this agent." ) per_file_view_window_char_limit: Mapped[Optional[int]] = mapped_column( Integer, nullable=True, doc="The per-file view window character limit for this agent." ) # indexing controls hidden: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True, default=None, doc="If set to True, the agent will be hidden.") _vector_db_namespace: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="Private field for vector database namespace") # relationships organization: Mapped["Organization"] = relationship("Organization", back_populates="agents", lazy="raise") tool_exec_environment_variables: Mapped[List["AgentEnvironmentVariable"]] = relationship( "AgentEnvironmentVariable", back_populates="agent", cascade="all, delete-orphan", lazy="selectin", doc="Environment variables associated with this agent.", ) tools: Mapped[List["Tool"]] = relationship("Tool", secondary="tools_agents", lazy="selectin", passive_deletes=True) sources: Mapped[List["Source"]] = relationship("Source", secondary="sources_agents", lazy="selectin") core_memory: Mapped[List["Block"]] = relationship( "Block", secondary="blocks_agents", lazy="selectin", passive_deletes=True, # Ensures SQLAlchemy doesn't fetch blocks_agents rows before deleting back_populates="agents", doc="Blocks forming the core memory of the agent.", ) tags: Mapped[List["AgentsTags"]] = relationship( "AgentsTags", back_populates="agent", cascade="all, delete-orphan", lazy="selectin", doc="Tags associated with the agent.", ) runs: Mapped[List["Run"]] = relationship( "Run", back_populates="agent", cascade="all, delete-orphan", lazy="raise", doc="Runs associated with the agent.", ) identities: Mapped[List["Identity"]] = relationship( "Identity", secondary="identities_agents", lazy="selectin", back_populates="agents", passive_deletes=True, ) groups: Mapped[List["Group"]] = relationship( "Group", secondary="groups_agents", lazy="raise", back_populates="agents", passive_deletes=True, ) multi_agent_group: Mapped["Group"] = relationship( "Group", lazy="selectin", viewonly=True, back_populates="manager_agent", foreign_keys="[Group.manager_agent_id]", uselist=False, ) batch_items: Mapped[List["LLMBatchItem"]] = relationship("LLMBatchItem", back_populates="agent", lazy="raise") file_agents: Mapped[List["FileAgent"]] = relationship( "FileAgent", back_populates="agent", cascade="all, delete-orphan", lazy="selectin", ) archives_agents: Mapped[List["ArchivesAgents"]] = relationship( "ArchivesAgents", back_populates="agent", cascade="all, delete-orphan", lazy="noload", doc="Archives accessible by this agent.", ) conversations: Mapped[List["Conversation"]] = relationship( "Conversation", back_populates="agent", cascade="all, delete-orphan", lazy="raise", doc="Conversations for concurrent messaging on this agent.", ) def _get_per_file_view_window_char_limit(self) -> int: """Get the per_file_view_window_char_limit, calculating defaults if None.""" if self.per_file_view_window_char_limit is not None: return self.per_file_view_window_char_limit context_window = self.llm_config.context_window if self.llm_config and self.llm_config.context_window else None _, default_char_limit = calculate_file_defaults_based_on_context_window(context_window) return default_char_limit def to_pydantic(self, include_relationships: Optional[Set[str]] = None) -> PydanticAgentState: """ Converts the SQLAlchemy Agent model into its Pydantic counterpart. The following base fields are always included: - id, agent_type, name, description, system, message_ids, metadata_, llm_config, embedding_config, project_id, template_id, base_template_id, tool_rules, message_buffer_autoclear, tags Everything else (e.g., tools, sources, memory, etc.) is optional and only included if specified in `include_fields`. Args: include_relationships (Optional[Set[str]]): A set of additional field names to include in the output. If None or empty, no extra fields are loaded beyond the base fields. Returns: PydanticAgentState: The Pydantic representation of the agent. """ # Base fields: always included state = { "id": self.id, "agent_type": self.agent_type, "name": self.name, "description": self.description, "system": self.system, "message_ids": self.message_ids, "metadata": self.metadata_, # Exposed as 'metadata' to Pydantic "llm_config": self.llm_config, "embedding_config": self.embedding_config, "compaction_settings": self.compaction_settings, "project_id": self.project_id, "template_id": self.template_id, "base_template_id": self.base_template_id, "deployment_id": self.deployment_id, "entity_id": self.entity_id, "tool_rules": self.tool_rules, "message_buffer_autoclear": self.message_buffer_autoclear, "created_by_id": self.created_by_id, "last_updated_by_id": self.last_updated_by_id, "created_at": self.created_at, "updated_at": self.updated_at, "enable_sleeptime": self.enable_sleeptime, "response_format": self.response_format, "last_run_completion": self.last_run_completion, "last_run_duration_ms": self.last_run_duration_ms, "last_stop_reason": self.last_stop_reason, "timezone": self.timezone, "max_files_open": self.max_files_open, "per_file_view_window_char_limit": self.per_file_view_window_char_limit, "hidden": self.hidden, # optional field defaults "tags": [], "tools": [], "sources": [], "memory": Memory(blocks=[]), "blocks": [], "identity_ids": [], "identities": [], "multi_agent_group": None, "tool_exec_environment_variables": [], "secrets": [], } # Optional fields: only included if requested optional_fields = { "tags": lambda: [t.tag for t in self.tags], "tools": lambda: self.tools, "sources": lambda: [s.to_pydantic() for s in self.sources], "memory": lambda: Memory( blocks=[b.to_pydantic() for b in self.core_memory], file_blocks=[ block for b in self.file_agents if (block := b.to_pydantic_block(per_file_view_window_char_limit=self._get_per_file_view_window_char_limit())) is not None ], agent_type=self.agent_type, ), "blocks": lambda: [b.to_pydantic() for b in self.core_memory], "identity_ids": lambda: [i.id for i in self.identities], "identities": lambda: [i.to_pydantic() for i in self.identities], # TODO: fix this "multi_agent_group": lambda: self.multi_agent_group, "managed_group": lambda: self.multi_agent_group, "tool_exec_environment_variables": lambda: self.tool_exec_environment_variables, "secrets": lambda: self.tool_exec_environment_variables, } include_relationships = set(optional_fields.keys() if include_relationships is None else include_relationships) for field_name in include_relationships: resolver = optional_fields.get(field_name) if resolver: state[field_name] = resolver() state["model"] = self.llm_config.handle if self.llm_config else None state["model_settings"] = self.llm_config._to_model_settings() if self.llm_config else None state["embedding"] = self.embedding_config.handle if self.embedding_config else None return self.__pydantic_model__(**state) async def _get_pending_approval_async(self) -> Optional[Any]: if self.message_ids and len(self.message_ids) > 0: # Try to get the async session this object is attached to session = async_object_session(self) if not session: # Object is detached, can't safely query return None latest_message_id = self.message_ids[-1] result = await session.execute(select(MessageModel).where(MessageModel.id == latest_message_id)) latest_message = result.scalar_one_or_none() if ( latest_message and latest_message.role == "approval" and latest_message.tool_calls is not None and len(latest_message.tool_calls) > 0 ): pydantic_message = latest_message.to_pydantic() return pydantic_message._convert_approval_request_message() return None async def to_pydantic_async( self, include_relationships: Optional[Set[str]] = None, include: Optional[List[str]] = None, decrypt: bool = True, ) -> PydanticAgentState: """ Converts the SQLAlchemy Agent model into its Pydantic counterpart. The following base fields are always included: - id, agent_type, name, description, system, message_ids, metadata_, llm_config, embedding_config, project_id, template_id, base_template_id, tool_rules, message_buffer_autoclear, tags Everything else (e.g., tools, sources, memory, etc.) is optional and only included if specified in `include_fields`. Args: include_relationships (Optional[Set[str]]): A set of additional field names to include in the output. If None or empty, no extra fields are loaded beyond the base fields. Returns: PydanticAgentState: The Pydantic representation of the agent. """ # Base fields: always included state = { "id": self.id, "agent_type": self.agent_type, "name": self.name, "description": self.description, "system": self.system, "message_ids": self.message_ids, "metadata": self.metadata_, # Exposed as 'metadata' to Pydantic "llm_config": self.llm_config, "embedding_config": self.embedding_config, "compaction_settings": self.compaction_settings, "project_id": self.project_id, "template_id": self.template_id, "base_template_id": self.base_template_id, "deployment_id": self.deployment_id, "entity_id": self.entity_id, "tool_rules": self.tool_rules, "message_buffer_autoclear": self.message_buffer_autoclear, "created_by_id": self.created_by_id, "last_updated_by_id": self.last_updated_by_id, "created_at": self.created_at, "updated_at": self.updated_at, "timezone": self.timezone, "enable_sleeptime": self.enable_sleeptime, "response_format": self.response_format, "last_run_completion": self.last_run_completion, "last_run_duration_ms": self.last_run_duration_ms, "last_stop_reason": self.last_stop_reason, "max_files_open": self.max_files_open, "per_file_view_window_char_limit": self.per_file_view_window_char_limit, "hidden": self.hidden, } optional_fields = { "tags": [], "tools": [], "sources": [], "memory": Memory(blocks=[]), "blocks": [], "identity_ids": [], "identities": [], "multi_agent_group": None, "managed_group": None, "tool_exec_environment_variables": [], "secrets": [], "pending_approval": None, } # Initialize include_relationships to an empty set if it's None include_relationships = set(optional_fields.keys() if include_relationships is None else include_relationships) # Convert include list to set for efficient membership checks include_set = set(include) if include else set() async def empty_list_async(): return [] async def none_async(): return None # Only load requested relationships tags = self.awaitable_attrs.tags if "tags" in include_relationships or "agent.tags" in include_set else empty_list_async() tools = self.awaitable_attrs.tools if "tools" in include_relationships or "agent.tools" in include_set else empty_list_async() sources = ( self.awaitable_attrs.sources if "sources" in include_relationships or "agent.sources" in include_set else empty_list_async() ) memory = ( self.awaitable_attrs.core_memory if "memory" in include_relationships or "agent.blocks" in include_set else empty_list_async() ) identities = ( self.awaitable_attrs.identities if "identity_ids" in include_relationships or "agent.identities" in include_set else empty_list_async() ) multi_agent_group = ( self.awaitable_attrs.multi_agent_group if "multi_agent_group" in include_relationships or "agent.managed_group" in include_set else none_async() ) tool_exec_environment_variables = ( self.awaitable_attrs.tool_exec_environment_variables if "tool_exec_environment_variables" in include_relationships or "secrets" in include_relationships or "agent.secrets" in include_set else empty_list_async() ) file_agents = ( self.awaitable_attrs.file_agents if "memory" in include_relationships or "agent.blocks" in include_set else empty_list_async() ) pending_approval = self._get_pending_approval_async() if "agent.pending_approval" in include_set else none_async() ( tags, tools, sources, memory, identities, multi_agent_group, tool_exec_environment_variables, file_agents, pending_approval, ) = await asyncio.gather( tags, tools, sources, memory, identities, multi_agent_group, tool_exec_environment_variables, file_agents, pending_approval ) state["tags"] = [t.tag for t in tags] state["tools"] = [t.to_pydantic() for t in tools] state["sources"] = [s.to_pydantic() for s in sources] state["memory"] = Memory( blocks=[m.to_pydantic() for m in memory], file_blocks=[ block for b in file_agents if (block := b.to_pydantic_block(per_file_view_window_char_limit=self._get_per_file_view_window_char_limit())) is not None ], agent_type=self.agent_type, ) state["blocks"] = [m.to_pydantic() for m in memory] state["identity_ids"] = [i.id for i in identities] state["identities"] = [i.to_pydantic() for i in identities] state["multi_agent_group"] = multi_agent_group state["managed_group"] = multi_agent_group # Convert ORM env vars to Pydantic, optionally skipping decryption if decrypt: env_vars_pydantic = await bounded_gather([PydanticAgentEnvVar.from_orm_async(e) for e in tool_exec_environment_variables]) else: # Skip decryption - return with encrypted values (faster, no PBKDF2) from letta.schemas.environment_variables import AgentEnvironmentVariable from letta.schemas.secret import Secret env_vars_pydantic = [] for e in tool_exec_environment_variables: data = { "id": e.id, "key": e.key, "description": e.description, "organization_id": e.organization_id, "agent_id": e.agent_id, "value": ENCRYPTED_PLACEHOLDER, "value_enc": Secret.from_encrypted(e.value_enc) if e.value_enc else None, } env_vars_pydantic.append(AgentEnvironmentVariable.model_validate(data)) state["tool_exec_environment_variables"] = env_vars_pydantic state["secrets"] = env_vars_pydantic state["pending_approval"] = pending_approval state["model"] = self.llm_config.handle if self.llm_config else None state["model_settings"] = self.llm_config._to_model_settings() if self.llm_config else None state["embedding"] = self.embedding_config.handle if self.embedding_config else None return self.__pydantic_model__(**state)