diff --git a/letta/orm/files_agents.py b/letta/orm/files_agents.py index 4ae6dd3b..2a4cfde2 100644 --- a/letta/orm/files_agents.py +++ b/letta/orm/files_agents.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from letta.constants import FILE_IS_TRUNCATED_WARNING from letta.orm.mixins import OrganizationMixin from letta.orm.sqlalchemy_base import SqlalchemyBase -from letta.schemas.block import Block as PydanticBlock +from letta.schemas.block import FileBlock as PydanticFileBlock from letta.schemas.file import FileAgent as PydanticFileAgent if TYPE_CHECKING: @@ -59,7 +59,7 @@ class FileAgent(SqlalchemyBase, OrganizationMixin): String, ForeignKey("sources.id", ondelete="CASCADE"), nullable=False, - doc="ID of the source (denormalized from files.source_id)", + doc="ID of the source", ) file_name: Mapped[str] = mapped_column( @@ -86,7 +86,7 @@ class FileAgent(SqlalchemyBase, OrganizationMixin): ) # TODO: This is temporary as we figure out if we want FileBlock as a first class citizen - def to_pydantic_block(self, per_file_view_window_char_limit: int) -> PydanticBlock: + def to_pydantic_block(self, per_file_view_window_char_limit: int) -> PydanticFileBlock: visible_content = self.visible_content if self.visible_content and self.is_open else "" # Truncate content and add warnings here when converting from FileAgent to Block @@ -95,10 +95,13 @@ class FileAgent(SqlalchemyBase, OrganizationMixin): visible_content = visible_content[: per_file_view_window_char_limit - len(truncated_warning)] visible_content += truncated_warning - return PydanticBlock( + return PydanticFileBlock( value=visible_content, - label=self.file_name, # use denormalized file_name instead of self.file.file_name + label=self.file_name, read_only=True, - metadata={"source_id": self.source_id}, # use denormalized source_id + file_id=self.file_id, + source_id=self.source_id, + is_open=self.is_open, + last_accessed_at=self.last_accessed_at, limit=per_file_view_window_char_limit, ) diff --git a/letta/schemas/agent.py b/letta/schemas/agent.py index 3da7bde2..54f5796d 100644 --- a/letta/schemas/agent.py +++ b/letta/schemas/agent.py @@ -370,7 +370,7 @@ def get_prompt_template_for_agent_type(agent_type: Optional[AgentType] = None): "{% endif %}" "{% if file_blocks %}" "{% for block in file_blocks %}" - "{% if block.metadata and block.metadata.get('source_id') == source.id %}" + "{% if block.source_id and block.source_id == source.id %}" f"\n" "<{{ block.label }}>\n" "\n" @@ -437,7 +437,7 @@ def get_prompt_template_for_agent_type(agent_type: Optional[AgentType] = None): "{% endif %}" "{% if file_blocks %}" "{% for block in file_blocks %}" - "{% if block.metadata and block.metadata.get('source_id') == source.id %}" + "{% if block.source_id and block.source_id == source.id %}" f"\n" "{% if block.description %}" "\n" @@ -503,7 +503,7 @@ def get_prompt_template_for_agent_type(agent_type: Optional[AgentType] = None): "{% endif %}" "{% if file_blocks %}" "{% for block in file_blocks %}" - "{% if block.metadata and block.metadata.get('source_id') == source.id %}" + "{% if block.source_id and block.source_id == source.id %}" f"\n" "{% if block.description %}" "\n" diff --git a/letta/schemas/block.py b/letta/schemas/block.py index ea9f12d4..ea30da77 100644 --- a/letta/schemas/block.py +++ b/letta/schemas/block.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from pydantic import Field, model_validator @@ -79,6 +80,16 @@ class Block(BaseBlock): last_updated_by_id: Optional[str] = Field(None, description="The id of the user that last updated this Block.") +class FileBlock(Block): + file_id: str = Field(..., description="Unique identifier of the file.") + source_id: str = Field(..., description="Unique identifier of the source.") + is_open: bool = Field(..., description="True if the agent currently has the file open.") + last_accessed_at: Optional[datetime] = Field( + default_factory=datetime.utcnow, + description="UTC timestamp of the agent’s most recent access to this file. Any operations from the open, close, or search tools will update this field.", + ) + + class Human(Block): """Human block of the LLM context""" diff --git a/letta/schemas/file.py b/letta/schemas/file.py index 4a3c876f..11a3356d 100644 --- a/letta/schemas/file.py +++ b/letta/schemas/file.py @@ -67,7 +67,7 @@ class FileAgentBase(LettaBase): # Core file-agent association fields agent_id: str = Field(..., description="Unique identifier of the agent.") file_id: str = Field(..., description="Unique identifier of the file.") - source_id: str = Field(..., description="Unique identifier of the source (denormalized from files.source_id).") + source_id: str = Field(..., description="Unique identifier of the source.") file_name: str = Field(..., description="Name of the file.") is_open: bool = Field(True, description="True if the agent currently has the file open.") visible_content: Optional[str] = Field( diff --git a/letta/schemas/memory.py b/letta/schemas/memory.py index 829bf737..bb856676 100644 --- a/letta/schemas/memory.py +++ b/letta/schemas/memory.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from openai.types.beta.function_tool import FunctionTool as OpenAITool from letta.constants import CORE_MEMORY_BLOCK_CHAR_LIMIT -from letta.schemas.block import Block +from letta.schemas.block import Block, FileBlock from letta.schemas.message import Message @@ -66,8 +66,8 @@ class Memory(BaseModel, validate_assignment=True): # Memory.block contains the list of memory blocks in the core memory blocks: List[Block] = Field(..., description="Memory blocks contained in the agent's in-context memory") - file_blocks: List[Block] = Field( - default_factory=list, description="Blocks representing the agent's in-context memory of an attached file" + file_blocks: List[FileBlock] = Field( + default_factory=list, description="Special blocks representing the agent's in-context memory of an attached file" ) @field_validator("file_blocks") diff --git a/letta/services/files_agents_manager.py b/letta/services/files_agents_manager.py index 38b98138..242ad8f9 100644 --- a/letta/services/files_agents_manager.py +++ b/letta/services/files_agents_manager.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import List, Optional +from typing import List, Optional, Union from sqlalchemy import and_, func, select, update @@ -8,6 +8,7 @@ from letta.orm.errors import NoResultFound from letta.orm.files_agents import FileAgent as FileAgentModel from letta.otel.tracing import trace_method from letta.schemas.block import Block as PydanticBlock +from letta.schemas.block import FileBlock as PydanticFileBlock from letta.schemas.file import FileAgent as PydanticFileAgent from letta.schemas.file import FileMetadata from letta.schemas.user import User as PydanticUser @@ -231,7 +232,7 @@ class FileAgentManager: actor: PydanticUser, is_open_only: bool = False, return_as_blocks: bool = False, - ) -> List[PydanticFileAgent]: + ) -> Union[List[PydanticFileAgent], List[PydanticFileBlock]]: """Return associations for *agent_id* (filtering by `is_open` if asked).""" async with db_registry.async_session() as session: conditions = [ @@ -351,7 +352,7 @@ class FileAgentManager: agent_id: ID of the agent file_id: ID of the file to open file_name: Name of the file to open - source_id: ID of the source (denormalized from files.source_id) + source_id: ID of the source actor: User performing the action visible_content: Content to set for the opened file