diff --git a/alembic/versions/5b804970e6a0_add_hidden_property_to_groups_and_blocks.py b/alembic/versions/5b804970e6a0_add_hidden_property_to_groups_and_blocks.py new file mode 100644 index 00000000..6f97ddd4 --- /dev/null +++ b/alembic/versions/5b804970e6a0_add_hidden_property_to_groups_and_blocks.py @@ -0,0 +1,35 @@ +"""add_hidden_property_to_groups_and_blocks + +Revision ID: 5b804970e6a0 +Revises: ddb69be34a72 +Create Date: 2025-09-03 22:19:03.825077 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "5b804970e6a0" +down_revision: Union[str, None] = "ddb69be34a72" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add hidden column to groups table + op.add_column("groups", sa.Column("hidden", sa.Boolean(), nullable=True)) + + # Add hidden column to block table + op.add_column("block", sa.Column("hidden", sa.Boolean(), nullable=True)) + + +def downgrade() -> None: + # Remove hidden column from block table + op.drop_column("block", "hidden") + + # Remove hidden column from groups table + op.drop_column("groups", "hidden") diff --git a/letta/orm/block.py b/letta/orm/block.py index 0d3d1605..4fe3c78b 100644 --- a/letta/orm/block.py +++ b/letta/orm/block.py @@ -41,6 +41,7 @@ class Block(OrganizationMixin, SqlalchemyBase, ProjectMixin, TemplateEntityMixin # permissions of the agent read_only: Mapped[bool] = mapped_column(doc="whether the agent has read-only access to the block", default=False) + hidden: Mapped[Optional[bool]] = mapped_column(nullable=True, doc="If set to True, the block will be hidden.") # history pointers / locking mechanisms current_history_entry_id: Mapped[Optional[str]] = mapped_column( diff --git a/letta/orm/group.py b/letta/orm/group.py index f01e8357..5b2c7e57 100644 --- a/letta/orm/group.py +++ b/letta/orm/group.py @@ -24,6 +24,7 @@ class Group(SqlalchemyBase, OrganizationMixin, ProjectMixin, TemplateMixin): min_message_buffer_length: Mapped[Optional[int]] = mapped_column(nullable=True, doc="") turns_counter: Mapped[Optional[int]] = mapped_column(nullable=True, doc="") last_processed_message_id: Mapped[Optional[str]] = mapped_column(nullable=True, doc="") + hidden: Mapped[Optional[bool]] = mapped_column(nullable=True, doc="If set to True, the group will be hidden.") # relationships organization: Mapped["Organization"] = relationship("Organization", back_populates="groups") diff --git a/letta/schemas/block.py b/letta/schemas/block.py index 10864954..c1e29e7f 100644 --- a/letta/schemas/block.py +++ b/letta/schemas/block.py @@ -38,6 +38,10 @@ class BaseBlock(LettaBase, validate_assignment=True): # metadata description: Optional[str] = Field(None, description="Description of the block.") metadata: Optional[dict] = Field({}, description="Metadata of the block.") + hidden: Optional[bool] = Field( + None, + description="If set to True, the block will be hidden.", + ) # def __len__(self): # return len(self.value) diff --git a/letta/schemas/group.py b/letta/schemas/group.py index 8cca0948..2bc82c89 100644 --- a/letta/schemas/group.py +++ b/letta/schemas/group.py @@ -49,6 +49,10 @@ class Group(GroupBase): None, description="The desired minimum length of messages in the context window of the convo agent. This is a best effort, and may be off-by-one due to user/assistant interleaving.", ) + hidden: Optional[bool] = Field( + None, + description="If set to True, the group will be hidden.", + ) @property def manager_config(self) -> ManagerConfig: @@ -170,6 +174,10 @@ class GroupCreate(BaseModel): manager_config: ManagerConfigUnion = Field(RoundRobinManager(), description="") project_id: Optional[str] = Field(None, description="The associated project id.") shared_block_ids: List[str] = Field([], description="") + hidden: Optional[bool] = Field( + None, + description="If set to True, the group will be hidden.", + ) class InternalTemplateGroupCreate(GroupCreate): diff --git a/letta/server/rest_api/routers/v1/blocks.py b/letta/server/rest_api/routers/v1/blocks.py index 140e534f..52d0d26e 100644 --- a/letta/server/rest_api/routers/v1/blocks.py +++ b/letta/server/rest_api/routers/v1/blocks.py @@ -68,6 +68,11 @@ async def list_blocks( "If provided, returns blocks that have exactly this number of connected agents." ), ), + show_hidden_blocks: bool | None = Query( + False, + include_in_schema=False, + description="If set to True, include blocks marked as hidden in the results.", + ), server: SyncServer = Depends(get_letta_server), actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present ): @@ -89,6 +94,7 @@ async def list_blocks( connected_to_agents_count_eq=connected_to_agents_count_eq, limit=limit, after=after, + show_hidden_blocks=show_hidden_blocks, ) diff --git a/letta/server/rest_api/routers/v1/groups.py b/letta/server/rest_api/routers/v1/groups.py index 8359b163..0093a518 100644 --- a/letta/server/rest_api/routers/v1/groups.py +++ b/letta/server/rest_api/routers/v1/groups.py @@ -25,6 +25,11 @@ async def list_groups( after: Optional[str] = Query(None, description="Cursor for pagination"), limit: Optional[int] = Query(None, description="Limit for pagination"), project_id: Optional[str] = Query(None, description="Search groups by project id"), + show_hidden_groups: bool | None = Query( + False, + include_in_schema=False, + description="If set to True, include groups marked as hidden in the results.", + ), ): """ Fetch all multi-agent groups matching query. @@ -37,6 +42,7 @@ async def list_groups( before=before, after=after, limit=limit, + show_hidden_groups=show_hidden_groups, ) diff --git a/letta/services/block_manager.py b/letta/services/block_manager.py index 0635fc42..0e0b4447 100644 --- a/letta/services/block_manager.py +++ b/letta/services/block_manager.py @@ -188,6 +188,7 @@ class BlockManager: connected_to_agents_count_lt: Optional[int] = None, connected_to_agents_count_eq: Optional[List[int]] = None, ascending: bool = True, + show_hidden_blocks: Optional[bool] = None, ) -> List[PydanticBlock]: """Async version of get_blocks method. Retrieve blocks based on various optional filters.""" from sqlalchemy import select @@ -228,6 +229,10 @@ class BlockManager: if value_search: query = query.where(BlockModel.value.ilike(f"%{value_search}%")) + # Apply hidden filter + if not show_hidden_blocks: + query = query.where((BlockModel.hidden.is_(None)) | (BlockModel.hidden == False)) + needs_distinct = False needs_agent_count_join = any( diff --git a/letta/services/group_manager.py b/letta/services/group_manager.py index 8b2a9b7f..1427e7c7 100644 --- a/letta/services/group_manager.py +++ b/letta/services/group_manager.py @@ -1,6 +1,7 @@ +from datetime import datetime from typing import List, Optional, Union -from sqlalchemy import delete, select +from sqlalchemy import and_, asc, delete, desc, or_, select from sqlalchemy.orm import Session from letta.orm.agent import Agent as AgentModel @@ -13,6 +14,7 @@ from letta.schemas.letta_message import LettaMessage from letta.schemas.message import Message as PydanticMessage from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry +from letta.settings import DatabaseChoice, settings from letta.utils import enforce_types @@ -27,20 +29,34 @@ class GroupManager: before: Optional[str] = None, after: Optional[str] = None, limit: Optional[int] = 50, + show_hidden_groups: Optional[bool] = None, ) -> list[PydanticGroup]: async with db_registry.async_session() as session: - filters = {"organization_id": actor.organization_id} + from sqlalchemy import select + + from letta.orm.sqlalchemy_base import AccessType + + query = select(GroupModel) + query = GroupModel.apply_access_predicate(query, actor, ["read"], AccessType.ORGANIZATION) + + # Apply filters if project_id: - filters["project_id"] = project_id + query = query.where(GroupModel.project_id == project_id) if manager_type: - filters["manager_type"] = manager_type - groups = await GroupModel.list_async( - db_session=session, - before=before, - after=after, - limit=limit, - **filters, - ) + query = query.where(GroupModel.manager_type == manager_type) + + # Apply hidden filter + if not show_hidden_groups: + query = query.where((GroupModel.hidden.is_(None)) | (GroupModel.hidden == False)) + + # Apply pagination + query = await _apply_group_pagination_async(query, before, after, session, ascending=True) + + if limit: + query = query.limit(limit) + + result = await session.execute(query) + groups = result.scalars().all() return [group.to_pydantic() for group in groups] @enforce_types @@ -561,3 +577,50 @@ class GroupManager: # 3) ordering if max_value <= min_value: raise ValueError(f"'{max_name}' must be greater than '{min_name}' (got {max_name}={max_value} <= {min_name}={min_value})") + + +def _cursor_filter(sort_col, id_col, ref_sort_col, ref_id, forward: bool): + """ + Returns a SQLAlchemy filter expression for cursor-based pagination for groups. + + If `forward` is True, returns records after the reference. + If `forward` is False, returns records before the reference. + """ + if forward: + return or_( + sort_col > ref_sort_col, + and_(sort_col == ref_sort_col, id_col > ref_id), + ) + else: + return or_( + sort_col < ref_sort_col, + and_(sort_col == ref_sort_col, id_col < ref_id), + ) + + +async def _apply_group_pagination_async(query, before: Optional[str], after: Optional[str], session, ascending: bool = True) -> any: + """Apply cursor-based pagination to group queries.""" + sort_column = GroupModel.created_at + + if after: + result = (await session.execute(select(sort_column, GroupModel.id).where(GroupModel.id == after))).first() + if result: + after_sort_value, after_id = result + # SQLite does not support as granular timestamping, so we need to round the timestamp + if settings.database_engine is DatabaseChoice.SQLITE and isinstance(after_sort_value, datetime): + after_sort_value = after_sort_value.strftime("%Y-%m-%d %H:%M:%S") + query = query.where(_cursor_filter(sort_column, GroupModel.id, after_sort_value, after_id, forward=ascending)) + + if before: + result = (await session.execute(select(sort_column, GroupModel.id).where(GroupModel.id == before))).first() + if result: + before_sort_value, before_id = result + # SQLite does not support as granular timestamping, so we need to round the timestamp + if settings.database_engine is DatabaseChoice.SQLITE and isinstance(before_sort_value, datetime): + before_sort_value = before_sort_value.strftime("%Y-%m-%d %H:%M:%S") + query = query.where(_cursor_filter(sort_column, GroupModel.id, before_sort_value, before_id, forward=not ascending)) + + # Apply ordering + order_fn = asc if ascending else desc + query = query.order_by(order_fn(sort_column), order_fn(GroupModel.id)) + return query