chore: clean up api (#2384)

Co-authored-by: Shubham Naik <shubham.naik10@gmail.com>
Co-authored-by: Shubham Naik <shub@memgpt.ai>
Co-authored-by: Matthew Zhou <mattzh1314@gmail.com>
Co-authored-by: mlong93 <35275280+mlong93@users.noreply.github.com>
Co-authored-by: Mindy Long <mindy@letta.com>
Co-authored-by: Kevin Lin <klin5061@gmail.com>
Co-authored-by: Charles Packer <packercharles@gmail.com>
This commit is contained in:
cthomas 2025-01-23 21:38:11 -08:00 committed by GitHub
parent 44293191ec
commit dbe58abe92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 1504 additions and 733 deletions

View File

@ -0,0 +1,43 @@
"""adding indexes to models
Revision ID: 6fbe9cace832
Revises: f895232c144a
Create Date: 2025-01-23 11:02:59.534372
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6fbe9cace832"
down_revision: Union[str, None] = "f895232c144a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_index("agent_passages_created_at_id_idx", "agent_passages", ["created_at", "id"], unique=False)
op.create_index("ix_agents_created_at", "agents", ["created_at", "id"], unique=False)
op.create_index("created_at_label_idx", "block", ["created_at", "label"], unique=False)
op.create_index("ix_jobs_created_at", "jobs", ["created_at", "id"], unique=False)
op.create_index("ix_messages_created_at", "messages", ["created_at", "id"], unique=False)
op.create_index("source_passages_created_at_id_idx", "source_passages", ["created_at", "id"], unique=False)
op.create_index("source_created_at_id_idx", "sources", ["created_at", "id"], unique=False)
op.create_index("ix_tools_created_at_name", "tools", ["created_at", "name"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_tools_created_at_name", table_name="tools")
op.drop_index("source_created_at_id_idx", table_name="sources")
op.drop_index("source_passages_created_at_id_idx", table_name="source_passages")
op.drop_index("ix_messages_created_at", table_name="messages")
op.drop_index("ix_jobs_created_at", table_name="jobs")
op.drop_index("created_at_label_idx", table_name="block")
op.drop_index("ix_agents_created_at", table_name="agents")
op.drop_index("agent_passages_created_at_id_idx", table_name="agent_passages")
# ### end Alembic commands ###

View File

@ -46,7 +46,7 @@ response = client.agents.messages.send(
messages=[
MessageCreate(
role="user",
text="hello",
content="hello",
)
],
)
@ -59,7 +59,7 @@ response = client.agents.messages.send(
messages=[
MessageCreate(
role="system",
text="[system] user has logged in. send a friendly message.",
content="[system] user has logged in. send a friendly message.",
)
],
)

View File

@ -29,7 +29,7 @@ response = client.agents.messages.send(
messages=[
MessageCreate(
role="user",
text="hello",
content="hello",
)
],
)

View File

@ -43,7 +43,7 @@ def main():
messages=[
MessageCreate(
role="user",
text="Whats my name?",
content="Whats my name?",
)
],
)

View File

@ -64,7 +64,7 @@ response = client.agents.messages.send(
messages=[
MessageCreate(
role="user",
text="roll a dice",
content="roll a dice",
)
],
)
@ -100,7 +100,7 @@ client.agents.messages.send(
messages=[
MessageCreate(
role="user",
text="search your archival memory",
content="search your archival memory",
)
],
)

View File

@ -246,7 +246,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=\"Search archival for our company's vacation policies\",\n",
" content=\"Search archival for our company's vacation policies\",\n",
" )\n",
" ],\n",
")\n",
@ -528,7 +528,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=\"When is my birthday?\",\n",
" content=\"When is my birthday?\",\n",
" )\n",
" ],\n",
")\n",
@ -814,7 +814,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=\"Who founded OpenAI?\",\n",
" content=\"Who founded OpenAI?\",\n",
" )\n",
" ],\n",
")\n",
@ -952,7 +952,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=\"Who founded OpenAI?\",\n",
" content=\"Who founded OpenAI?\",\n",
" )\n",
" ],\n",
")\n",

View File

@ -169,7 +169,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=\"hello!\",\n",
" content=\"hello!\",\n",
" )\n",
" ],\n",
")\n",
@ -529,7 +529,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=\"My name is actually Bob\",\n",
" content=\"My name is actually Bob\",\n",
" )\n",
" ],\n",
")\n",
@ -682,7 +682,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=\"In the future, never use emojis to communicate\",\n",
" content=\"In the future, never use emojis to communicate\",\n",
" )\n",
" ],\n",
")\n",
@ -870,7 +870,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=\"Save the information that 'bob loves cats' to archival\",\n",
" content=\"Save the information that 'bob loves cats' to archival\",\n",
" )\n",
" ],\n",
")\n",
@ -1039,7 +1039,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=\"What animals do I like? Search archival.\",\n",
" content=\"What animals do I like? Search archival.\",\n",
" )\n",
" ],\n",
")\n",

View File

@ -276,7 +276,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=\"Candidate: Tony Stark\",\n",
" content=\"Candidate: Tony Stark\",\n",
" )\n",
" ],\n",
")"
@ -403,7 +403,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=feedback,\n",
" content=feedback,\n",
" )\n",
" ],\n",
")"
@ -423,7 +423,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"user\",\n",
" text=feedback,\n",
" content=feedback,\n",
" )\n",
" ],\n",
")"
@ -540,7 +540,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"system\",\n",
" text=\"Candidate: Spongebob Squarepants\",\n",
" content=\"Candidate: Spongebob Squarepants\",\n",
" )\n",
" ],\n",
")"
@ -758,7 +758,7 @@
" messages=[\n",
" MessageCreate(\n",
" role=\"system\",\n",
" text=\"Run generation\",\n",
" content=\"Run generation\",\n",
" )\n",
" ],\n",
")"

View File

@ -206,7 +206,7 @@ class AbstractClient(object):
) -> Tool:
raise NotImplementedError
def list_tools(self, cursor: Optional[str] = None, limit: Optional[int] = 50) -> List[Tool]:
def list_tools(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[Tool]:
raise NotImplementedError
def get_tool(self, id: str) -> Tool:
@ -266,7 +266,7 @@ class AbstractClient(object):
def list_attached_sources(self, agent_id: str) -> List[Source]:
raise NotImplementedError
def list_files_from_source(self, source_id: str, limit: int = 1000, cursor: Optional[str] = None) -> List[FileMetadata]:
def list_files_from_source(self, source_id: str, limit: int = 1000, after: Optional[str] = None) -> List[FileMetadata]:
raise NotImplementedError
def update_source(self, source_id: str, name: Optional[str] = None) -> Source:
@ -279,12 +279,12 @@ class AbstractClient(object):
raise NotImplementedError
def get_archival_memory(
self, agent_id: str, before: Optional[str] = None, after: Optional[str] = None, limit: Optional[int] = 1000
self, agent_id: str, after: Optional[str] = None, before: Optional[str] = None, limit: Optional[int] = 1000
) -> List[Passage]:
raise NotImplementedError
def get_messages(
self, agent_id: str, before: Optional[str] = None, after: Optional[str] = None, limit: Optional[int] = 1000
self, agent_id: str, after: Optional[str] = None, before: Optional[str] = None, limit: Optional[int] = 1000
) -> List[Message]:
raise NotImplementedError
@ -297,7 +297,7 @@ class AbstractClient(object):
def create_org(self, name: Optional[str] = None) -> Organization:
raise NotImplementedError
def list_orgs(self, cursor: Optional[str] = None, limit: Optional[int] = 50) -> List[Organization]:
def list_orgs(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[Organization]:
raise NotImplementedError
def delete_org(self, org_id: str) -> Organization:
@ -337,13 +337,13 @@ class AbstractClient(object):
"""
raise NotImplementedError
def list_sandbox_configs(self, limit: int = 50, cursor: Optional[str] = None) -> List[SandboxConfig]:
def list_sandbox_configs(self, limit: int = 50, after: Optional[str] = None) -> List[SandboxConfig]:
"""
List all sandbox configurations.
Args:
limit (int, optional): The maximum number of sandbox configurations to return. Defaults to 50.
cursor (Optional[str], optional): The pagination cursor for retrieving the next set of results.
after (Optional[str], optional): The pagination cursor for retrieving the next set of results.
Returns:
List[SandboxConfig]: A list of sandbox configurations.
@ -394,7 +394,7 @@ class AbstractClient(object):
raise NotImplementedError
def list_sandbox_env_vars(
self, sandbox_config_id: str, limit: int = 50, cursor: Optional[str] = None
self, sandbox_config_id: str, limit: int = 50, after: Optional[str] = None
) -> List[SandboxEnvironmentVariable]:
"""
List all environment variables associated with a sandbox configuration.
@ -402,7 +402,7 @@ class AbstractClient(object):
Args:
sandbox_config_id (str): The ID of the sandbox configuration to retrieve environment variables for.
limit (int, optional): The maximum number of environment variables to return. Defaults to 50.
cursor (Optional[str], optional): The pagination cursor for retrieving the next set of results.
after (Optional[str], optional): The pagination cursor for retrieving the next set of results.
Returns:
List[SandboxEnvironmentVariable]: A list of environment variables.
@ -477,7 +477,12 @@ class RESTClient(AbstractClient):
self._default_embedding_config = default_embedding_config
def list_agents(
self, tags: Optional[List[str]] = None, query_text: Optional[str] = None, limit: int = 50, cursor: Optional[str] = None
self,
tags: Optional[List[str]] = None,
query_text: Optional[str] = None,
limit: int = 50,
before: Optional[str] = None,
after: Optional[str] = None,
) -> List[AgentState]:
params = {"limit": limit}
if tags:
@ -487,11 +492,13 @@ class RESTClient(AbstractClient):
if query_text:
params["query_text"] = query_text
if cursor:
params["cursor"] = cursor
if before:
params["before"] = before
if after:
params["after"] = after
response = requests.get(f"{self.base_url}/{self.api_prefix}/agents", headers=self.headers, params=params)
print(f"\nLIST RESPONSE\n{response.json()}\n")
return [AgentState(**agent) for agent in response.json()]
def agent_exists(self, agent_id: str) -> bool:
@ -636,7 +643,7 @@ class RESTClient(AbstractClient):
) -> Message:
request = MessageUpdate(
role=role,
text=text,
content=text,
name=name,
tool_calls=tool_calls,
tool_call_id=tool_call_id,
@ -1009,7 +1016,7 @@ class RESTClient(AbstractClient):
response (LettaResponse): Response from the agent
"""
# TODO: implement include_full_message
messages = [MessageCreate(role=MessageRole(role), text=message, name=name)]
messages = [MessageCreate(role=MessageRole(role), content=message, name=name)]
# TODO: figure out how to handle stream_steps and stream_tokens
# When streaming steps is True, stream_tokens must be False
@ -1056,7 +1063,7 @@ class RESTClient(AbstractClient):
Returns:
job (Job): Information about the async job
"""
messages = [MessageCreate(role=MessageRole(role), text=message, name=name)]
messages = [MessageCreate(role=MessageRole(role), content=message, name=name)]
request = LettaRequest(messages=messages)
response = requests.post(
@ -1359,7 +1366,7 @@ class RESTClient(AbstractClient):
def load_data(self, connector: DataConnector, source_name: str):
raise NotImplementedError
def load_file_to_source(self, filename: str, source_id: str, blocking=True):
def load_file_to_source(self, filename: str, source_id: str, blocking=True) -> Job:
"""
Load a file into a source
@ -1427,20 +1434,20 @@ class RESTClient(AbstractClient):
raise ValueError(f"Failed to list attached sources: {response.text}")
return [Source(**source) for source in response.json()]
def list_files_from_source(self, source_id: str, limit: int = 1000, cursor: Optional[str] = None) -> List[FileMetadata]:
def list_files_from_source(self, source_id: str, limit: int = 1000, after: Optional[str] = None) -> List[FileMetadata]:
"""
List files from source with pagination support.
Args:
source_id (str): ID of the source
limit (int): Number of files to return
cursor (Optional[str]): Pagination cursor for fetching the next page
after (str): Get files after a certain time
Returns:
List[FileMetadata]: List of files
"""
# Prepare query parameters for pagination
params = {"limit": limit, "cursor": cursor}
params = {"limit": limit, "after": after}
# Make the request to the FastAPI endpoint
response = requests.get(f"{self.base_url}/{self.api_prefix}/sources/{source_id}/files", headers=self.headers, params=params)
@ -1640,7 +1647,7 @@ class RESTClient(AbstractClient):
raise ValueError(f"Failed to update tool: {response.text}")
return Tool(**response.json())
def list_tools(self, cursor: Optional[str] = None, limit: Optional[int] = 50) -> List[Tool]:
def list_tools(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[Tool]:
"""
List available tools for the user.
@ -1648,8 +1655,8 @@ class RESTClient(AbstractClient):
tools (List[Tool]): List of tools
"""
params = {}
if cursor:
params["cursor"] = str(cursor)
if after:
params["after"] = after
if limit:
params["limit"] = limit
@ -1728,15 +1735,15 @@ class RESTClient(AbstractClient):
raise ValueError(f"Failed to list embedding configs: {response.text}")
return [EmbeddingConfig(**config) for config in response.json()]
def list_orgs(self, cursor: Optional[str] = None, limit: Optional[int] = 50) -> List[Organization]:
def list_orgs(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[Organization]:
"""
Retrieves a list of all organizations in the database, with optional pagination.
@param cursor: the pagination cursor, if any
@param after: the pagination cursor, if any
@param limit: the maximum number of organizations to retrieve
@return: a list of Organization objects
"""
params = {"cursor": cursor, "limit": limit}
params = {"after": after, "limit": limit}
response = requests.get(f"{self.base_url}/{ADMIN_PREFIX}/orgs", headers=self.headers, params=params)
if response.status_code != 200:
raise ValueError(f"Failed to retrieve organizations: {response.text}")
@ -1779,6 +1786,12 @@ class RESTClient(AbstractClient):
def create_sandbox_config(self, config: Union[LocalSandboxConfig, E2BSandboxConfig]) -> SandboxConfig:
"""
Create a new sandbox configuration.
Args:
config (Union[LocalSandboxConfig, E2BSandboxConfig]): The sandbox settings.
Returns:
SandboxConfig: The created sandbox configuration.
"""
payload = {
"config": config.model_dump(),
@ -1791,6 +1804,13 @@ class RESTClient(AbstractClient):
def update_sandbox_config(self, sandbox_config_id: str, config: Union[LocalSandboxConfig, E2BSandboxConfig]) -> SandboxConfig:
"""
Update an existing sandbox configuration.
Args:
sandbox_config_id (str): The ID of the sandbox configuration to update.
config (Union[LocalSandboxConfig, E2BSandboxConfig]): The updated sandbox settings.
Returns:
SandboxConfig: The updated sandbox configuration.
"""
payload = {
"config": config.model_dump(),
@ -1807,6 +1827,9 @@ class RESTClient(AbstractClient):
def delete_sandbox_config(self, sandbox_config_id: str) -> None:
"""
Delete a sandbox configuration.
Args:
sandbox_config_id (str): The ID of the sandbox configuration to delete.
"""
response = requests.delete(f"{self.base_url}/{self.api_prefix}/sandbox-config/{sandbox_config_id}", headers=self.headers)
if response.status_code == 404:
@ -1814,11 +1837,18 @@ class RESTClient(AbstractClient):
elif response.status_code != 204:
raise ValueError(f"Failed to delete sandbox config with ID '{sandbox_config_id}': {response.text}")
def list_sandbox_configs(self, limit: int = 50, cursor: Optional[str] = None) -> List[SandboxConfig]:
def list_sandbox_configs(self, limit: int = 50, after: Optional[str] = None) -> List[SandboxConfig]:
"""
List all sandbox configurations.
Args:
limit (int, optional): The maximum number of sandbox configurations to return. Defaults to 50.
after (Optional[str], optional): The pagination cursor for retrieving the next set of results.
Returns:
List[SandboxConfig]: A list of sandbox configurations.
"""
params = {"limit": limit, "cursor": cursor}
params = {"limit": limit, "after": after}
response = requests.get(f"{self.base_url}/{self.api_prefix}/sandbox-config", headers=self.headers, params=params)
if response.status_code != 200:
raise ValueError(f"Failed to list sandbox configs: {response.text}")
@ -1829,6 +1859,15 @@ class RESTClient(AbstractClient):
) -> SandboxEnvironmentVariable:
"""
Create a new environment variable for a sandbox configuration.
Args:
sandbox_config_id (str): The ID of the sandbox configuration to associate the environment variable with.
key (str): The name of the environment variable.
value (str): The value of the environment variable.
description (Optional[str], optional): A description of the environment variable. Defaults to None.
Returns:
SandboxEnvironmentVariable: The created environment variable.
"""
payload = {"key": key, "value": value, "description": description}
response = requests.post(
@ -1845,6 +1884,15 @@ class RESTClient(AbstractClient):
) -> SandboxEnvironmentVariable:
"""
Update an existing environment variable.
Args:
env_var_id (str): The ID of the environment variable to update.
key (Optional[str], optional): The updated name of the environment variable. Defaults to None.
value (Optional[str], optional): The updated value of the environment variable. Defaults to None.
description (Optional[str], optional): The updated description of the environment variable. Defaults to None.
Returns:
SandboxEnvironmentVariable: The updated environment variable.
"""
payload = {k: v for k, v in {"key": key, "value": value, "description": description}.items() if v is not None}
response = requests.patch(
@ -1859,6 +1907,9 @@ class RESTClient(AbstractClient):
def delete_sandbox_env_var(self, env_var_id: str) -> None:
"""
Delete an environment variable by its ID.
Args:
env_var_id (str): The ID of the environment variable to delete.
"""
response = requests.delete(
f"{self.base_url}/{self.api_prefix}/sandbox-config/environment-variable/{env_var_id}", headers=self.headers
@ -1869,12 +1920,20 @@ class RESTClient(AbstractClient):
raise ValueError(f"Failed to delete environment variable with ID '{env_var_id}': {response.text}")
def list_sandbox_env_vars(
self, sandbox_config_id: str, limit: int = 50, cursor: Optional[str] = None
self, sandbox_config_id: str, limit: int = 50, after: Optional[str] = None
) -> List[SandboxEnvironmentVariable]:
"""
List all environment variables associated with a sandbox configuration.
Args:
sandbox_config_id (str): The ID of the sandbox configuration to retrieve environment variables for.
limit (int, optional): The maximum number of environment variables to return. Defaults to 50.
after (Optional[str], optional): The pagination cursor for retrieving the next set of results.
Returns:
List[SandboxEnvironmentVariable]: A list of environment variables.
"""
params = {"limit": limit, "cursor": cursor}
params = {"limit": limit, "after": after}
response = requests.get(
f"{self.base_url}/{self.api_prefix}/sandbox-config/{sandbox_config_id}/environment-variable",
headers=self.headers,
@ -2035,7 +2094,8 @@ class RESTClient(AbstractClient):
def get_run_messages(
self,
run_id: str,
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
limit: Optional[int] = 100,
ascending: bool = True,
role: Optional[MessageRole] = None,
@ -2045,7 +2105,8 @@ class RESTClient(AbstractClient):
Args:
job_id: ID of the job
cursor: Cursor for pagination
before: Cursor for pagination
after: Cursor for pagination
limit: Maximum number of messages to return
ascending: Sort order by creation time
role: Filter by message role (user/assistant/system/tool)
@ -2053,7 +2114,8 @@ class RESTClient(AbstractClient):
List of messages matching the filter criteria
"""
params = {
"cursor": cursor,
"before": before,
"after": after,
"limit": limit,
"ascending": ascending,
"role": role,
@ -2151,15 +2213,15 @@ class RESTClient(AbstractClient):
def get_tags(
self,
cursor: Optional[str] = None,
limit: Optional[int] = None,
after: Optional[str] = None,
limit: int = 100,
query_text: Optional[str] = None,
) -> List[str]:
"""
Get a list of all unique tags.
Args:
cursor: Optional cursor for pagination (last tag seen)
after: Optional cursor for pagination (first tag seen)
limit: Optional maximum number of tags to return
query_text: Optional text to filter tags
@ -2167,8 +2229,8 @@ class RESTClient(AbstractClient):
List[str]: List of unique tags
"""
params = {}
if cursor:
params["cursor"] = cursor
if after:
params["after"] = after
if limit:
params["limit"] = limit
if query_text:
@ -2238,11 +2300,18 @@ class LocalClient(AbstractClient):
# agents
def list_agents(
self, query_text: Optional[str] = None, tags: Optional[List[str]] = None, limit: int = 100, cursor: Optional[str] = None
self,
query_text: Optional[str] = None,
tags: Optional[List[str]] = None,
limit: int = 100,
before: Optional[str] = None,
after: Optional[str] = None,
) -> List[AgentState]:
self.interface.clear()
return self.server.agent_manager.list_agents(actor=self.user, tags=tags, query_text=query_text, limit=limit, cursor=cursor)
return self.server.agent_manager.list_agents(
actor=self.user, tags=tags, query_text=query_text, limit=limit, before=before, after=after
)
def agent_exists(self, agent_id: Optional[str] = None, agent_name: Optional[str] = None) -> bool:
"""
@ -2374,7 +2443,7 @@ class LocalClient(AbstractClient):
message_id=message_id,
request=MessageUpdate(
role=role,
text=text,
content=text,
name=name,
tool_calls=tool_calls,
tool_call_id=tool_call_id,
@ -2673,7 +2742,7 @@ class LocalClient(AbstractClient):
usage = self.server.send_messages(
actor=self.user,
agent_id=agent_id,
messages=[MessageCreate(role=MessageRole(role), text=message, name=name)],
messages=[MessageCreate(role=MessageRole(role), content=message, name=name)],
)
## TODO: need to make sure date/timestamp is propely passed
@ -2990,7 +3059,7 @@ class LocalClient(AbstractClient):
id: str,
name: Optional[str] = None,
description: Optional[str] = None,
func: Optional[callable] = None,
func: Optional[Callable] = None,
tags: Optional[List[str]] = None,
return_char_limit: int = FUNCTION_RETURN_CHAR_LIMIT,
) -> Tool:
@ -3021,14 +3090,14 @@ class LocalClient(AbstractClient):
return self.server.tool_manager.update_tool_by_id(tool_id=id, tool_update=ToolUpdate(**update_data), actor=self.user)
def list_tools(self, cursor: Optional[str] = None, limit: Optional[int] = 50) -> List[Tool]:
def list_tools(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[Tool]:
"""
List available tools for the user.
Returns:
tools (List[Tool]): List of tools
"""
return self.server.tool_manager.list_tools(cursor=cursor, limit=limit, actor=self.user)
return self.server.tool_manager.list_tools(after=after, limit=limit, actor=self.user)
def get_tool(self, id: str) -> Optional[Tool]:
"""
@ -3227,19 +3296,19 @@ class LocalClient(AbstractClient):
"""
return self.server.agent_manager.list_attached_sources(agent_id=agent_id, actor=self.user)
def list_files_from_source(self, source_id: str, limit: int = 1000, cursor: Optional[str] = None) -> List[FileMetadata]:
def list_files_from_source(self, source_id: str, limit: int = 1000, after: Optional[str] = None) -> List[FileMetadata]:
"""
List files from source.
Args:
source_id (str): ID of the source
limit (int): The # of items to return
cursor (str): The cursor for fetching the next page
after (str): The cursor for fetching the next page
Returns:
files (List[FileMetadata]): List of files
"""
return self.server.source_manager.list_files(source_id=source_id, limit=limit, cursor=cursor, actor=self.user)
return self.server.source_manager.list_files(source_id=source_id, limit=limit, after=after, actor=self.user)
def update_source(self, source_id: str, name: Optional[str] = None) -> Source:
"""
@ -3297,17 +3366,20 @@ class LocalClient(AbstractClient):
passages (List[Passage]): List of passages
"""
return self.server.get_agent_archival_cursor(user_id=self.user_id, agent_id=agent_id, limit=limit)
return self.server.get_agent_archival(user_id=self.user_id, agent_id=agent_id, limit=limit)
# recall memory
def get_messages(self, agent_id: str, cursor: Optional[str] = None, limit: Optional[int] = 1000) -> List[Message]:
def get_messages(
self, agent_id: str, before: Optional[str] = None, after: Optional[str] = None, limit: Optional[int] = 1000
) -> List[Message]:
"""
Get messages from an agent with pagination.
Args:
agent_id (str): ID of the agent
cursor (str): Get messages after a certain time
before (str): Get messages before a certain time
after (str): Get messages after a certain time
limit (int): Limit number of messages
Returns:
@ -3315,10 +3387,11 @@ class LocalClient(AbstractClient):
"""
self.interface.clear()
return self.server.get_agent_recall_cursor(
return self.server.get_agent_recall(
user_id=self.user_id,
agent_id=agent_id,
before=cursor,
before=before,
after=after,
limit=limit,
reverse=True,
)
@ -3437,8 +3510,8 @@ class LocalClient(AbstractClient):
def create_org(self, name: Optional[str] = None) -> Organization:
return self.server.organization_manager.create_organization(pydantic_org=Organization(name=name))
def list_orgs(self, cursor: Optional[str] = None, limit: Optional[int] = 50) -> List[Organization]:
return self.server.organization_manager.list_organizations(cursor=cursor, limit=limit)
def list_orgs(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[Organization]:
return self.server.organization_manager.list_organizations(limit=limit, after=after)
def delete_org(self, org_id: str) -> Organization:
return self.server.organization_manager.delete_organization_by_id(org_id=org_id)
@ -3465,11 +3538,11 @@ class LocalClient(AbstractClient):
"""
return self.server.sandbox_config_manager.delete_sandbox_config(sandbox_config_id=sandbox_config_id, actor=self.user)
def list_sandbox_configs(self, limit: int = 50, cursor: Optional[str] = None) -> List[SandboxConfig]:
def list_sandbox_configs(self, limit: int = 50, after: Optional[str] = None) -> List[SandboxConfig]:
"""
List all sandbox configurations.
"""
return self.server.sandbox_config_manager.list_sandbox_configs(actor=self.user, limit=limit, cursor=cursor)
return self.server.sandbox_config_manager.list_sandbox_configs(actor=self.user, limit=limit, after=after)
def create_sandbox_env_var(
self, sandbox_config_id: str, key: str, value: str, description: Optional[str] = None
@ -3500,13 +3573,13 @@ class LocalClient(AbstractClient):
return self.server.sandbox_config_manager.delete_sandbox_env_var(env_var_id=env_var_id, actor=self.user)
def list_sandbox_env_vars(
self, sandbox_config_id: str, limit: int = 50, cursor: Optional[str] = None
self, sandbox_config_id: str, limit: int = 50, after: Optional[str] = None
) -> List[SandboxEnvironmentVariable]:
"""
List all environment variables associated with a sandbox configuration.
"""
return self.server.sandbox_config_manager.list_sandbox_env_vars(
sandbox_config_id=sandbox_config_id, actor=self.user, limit=limit, cursor=cursor
sandbox_config_id=sandbox_config_id, actor=self.user, limit=limit, after=after
)
def update_agent_memory_block_label(self, agent_id: str, current_label: str, new_label: str) -> Memory:
@ -3627,7 +3700,8 @@ class LocalClient(AbstractClient):
def get_run_messages(
self,
run_id: str,
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
limit: Optional[int] = 100,
ascending: bool = True,
role: Optional[MessageRole] = None,
@ -3637,21 +3711,23 @@ class LocalClient(AbstractClient):
Args:
run_id: ID of the run
cursor: Cursor for pagination
before: Cursor for pagination
after: Cursor for pagination
limit: Maximum number of messages to return
ascending: Sort order by creation time
role: Filter by message role (user/assistant/system/tool)
Returns:
List of messages matching the filter criteria
"""
params = {
"cursor": cursor,
"before": before,
"after": after,
"limit": limit,
"ascending": ascending,
"role": role,
}
return self.server.job_manager.get_run_messages_cursor(run_id=run_id, actor=self.user, **params)
return self.server.job_manager.get_run_messages(run_id=run_id, actor=self.user, **params)
def get_run_usage(
self,
@ -3713,9 +3789,9 @@ class LocalClient(AbstractClient):
def get_tags(
self,
cursor: str = None,
limit: int = 100,
query_text: str = None,
after: Optional[str] = None,
limit: Optional[int] = None,
query_text: Optional[str] = None,
) -> List[str]:
"""
Get all tags.
@ -3723,4 +3799,4 @@ class LocalClient(AbstractClient):
Returns:
tags (List[str]): List of tags
"""
return self.server.agent_manager.list_tags(actor=self.user, cursor=cursor, limit=limit, query_text=query_text)
return self.server.agent_manager.list_tags(actor=self.user, after=after, limit=limit, query_text=query_text)

View File

@ -50,7 +50,7 @@ def _sse_post(url: str, data: dict, headers: dict) -> Generator[LettaStreamingRe
chunk_data = json.loads(sse.data)
if "reasoning" in chunk_data:
yield ReasoningMessage(**chunk_data)
elif "assistant_message" in chunk_data:
elif "message_type" in chunk_data and chunk_data["message_type"] == "assistant_message":
yield AssistantMessage(**chunk_data)
elif "tool_call" in chunk_data:
yield ToolCallMessage(**chunk_data)

View File

@ -6,7 +6,7 @@ import requests
from letta.constants import MESSAGE_CHATGPT_FUNCTION_MODEL, MESSAGE_CHATGPT_FUNCTION_SYSTEM_MESSAGE
from letta.llm_api.llm_api_tools import create
from letta.schemas.message import Message
from letta.schemas.message import Message, TextContent
from letta.utils import json_dumps, json_loads
@ -23,8 +23,13 @@ def message_chatgpt(self, message: str):
dummy_user_id = uuid.uuid4()
dummy_agent_id = uuid.uuid4()
message_sequence = [
Message(user_id=dummy_user_id, agent_id=dummy_agent_id, role="system", text=MESSAGE_CHATGPT_FUNCTION_SYSTEM_MESSAGE),
Message(user_id=dummy_user_id, agent_id=dummy_agent_id, role="user", text=str(message)),
Message(
user_id=dummy_user_id,
agent_id=dummy_agent_id,
role="system",
content=[TextContent(text=MESSAGE_CHATGPT_FUNCTION_SYSTEM_MESSAGE)],
),
Message(user_id=dummy_user_id, agent_id=dummy_agent_id, role="user", content=[TextContent(text=str(message))]),
]
# TODO: this will error without an LLMConfig
response = create(

View File

@ -74,7 +74,7 @@ def send_message_to_agents_matching_all_tags(self: "Agent", message: str, tags:
server = get_letta_server()
# Retrieve agents that match ALL specified tags
matching_agents = server.agent_manager.list_agents(actor=self.user, tags=tags, match_all_tags=True, cursor=None, limit=100)
matching_agents = server.agent_manager.list_agents(actor=self.user, tags=tags, match_all_tags=True, limit=100)
async def send_messages_to_all_agents():
tasks = [

View File

@ -246,7 +246,7 @@ def parse_letta_response_for_assistant_message(
reasoning_message = ""
for m in letta_response.messages:
if isinstance(m, AssistantMessage):
return m.assistant_message
return m.content
elif isinstance(m, ToolCallMessage) and m.tool_call.name == assistant_message_tool_name:
try:
return json.loads(m.tool_call.arguments)[assistant_message_tool_kwarg]
@ -290,7 +290,7 @@ async def async_send_message_with_retries(
logging_prefix = logging_prefix or "[async_send_message_with_retries]"
for attempt in range(1, max_retries + 1):
try:
messages = [MessageCreate(role=MessageRole.user, text=message_text, name=sender_agent.agent_state.name)]
messages = [MessageCreate(role=MessageRole.user, content=message_text, name=sender_agent.agent_state.name)]
# Wrap in a timeout
response = await asyncio.wait_for(
server.send_message_to_agent(

View File

@ -237,6 +237,7 @@ def create(
data=dict(
contents=[m.to_google_ai_dict() for m in messages],
tools=tools,
generation_config={"temperature": llm_config.temperature},
),
inner_thoughts_in_kwargs=llm_config.put_inner_thoughts_in_kwargs,
)
@ -261,6 +262,7 @@ def create(
# user=str(user_id),
# NOTE: max_tokens is required for Anthropic API
max_tokens=1024, # TODO make dynamic
temperature=llm_config.temperature,
),
)
@ -290,7 +292,6 @@ def create(
# # max_tokens=1024, # TODO make dynamic
# ),
# )
elif llm_config.model_endpoint_type == "groq":
if stream:
raise NotImplementedError(f"Streaming not yet implemented for Groq.")
@ -329,7 +330,6 @@ def create(
try:
# groq uses the openai chat completions API, so this component should be reusable
response = openai_chat_completions_request(
url=llm_config.model_endpoint,
api_key=model_settings.groq_api_key,
chat_completion_request=data,
)

View File

@ -1,14 +1,9 @@
import json
import warnings
from typing import Generator, List, Optional, Union
import httpx
import requests
from httpx_sse import connect_sse
from httpx_sse._exceptions import SSEError
from openai import OpenAI
from letta.constants import OPENAI_CONTEXT_WINDOW_ERROR_SUBSTRING
from letta.errors import LLMError
from letta.llm_api.helpers import add_inner_thoughts_to_functions, convert_to_structured_output, make_post_request
from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION
from letta.local_llm.utils import num_tokens_from_functions, num_tokens_from_messages
@ -130,7 +125,8 @@ def build_openai_chat_completions_request(
tools=[Tool(type="function", function=f) for f in functions] if functions else None,
tool_choice=tool_choice,
user=str(user_id),
max_tokens=max_tokens,
max_completion_tokens=max_tokens,
temperature=llm_config.temperature,
)
else:
data = ChatCompletionRequest(
@ -139,7 +135,8 @@ def build_openai_chat_completions_request(
functions=functions,
function_call=function_call,
user=str(user_id),
max_tokens=max_tokens,
max_completion_tokens=max_tokens,
temperature=llm_config.temperature,
)
# https://platform.openai.com/docs/guides/text-generation/json-mode
# only supported by gpt-4o, gpt-4-turbo, or gpt-3.5-turbo
@ -378,126 +375,21 @@ def openai_chat_completions_process_stream(
return chat_completion_response
def _sse_post(url: str, data: dict, headers: dict) -> Generator[ChatCompletionChunkResponse, None, None]:
with httpx.Client() as client:
with connect_sse(client, method="POST", url=url, json=data, headers=headers) as event_source:
# Inspect for errors before iterating (see https://github.com/florimondmanca/httpx-sse/pull/12)
if not event_source.response.is_success:
# handle errors
from letta.utils import printd
printd("Caught error before iterating SSE request:", vars(event_source.response))
printd(event_source.response.read())
try:
response_bytes = event_source.response.read()
response_dict = json.loads(response_bytes.decode("utf-8"))
error_message = response_dict["error"]["message"]
# e.g.: This model's maximum context length is 8192 tokens. However, your messages resulted in 8198 tokens (7450 in the messages, 748 in the functions). Please reduce the length of the messages or functions.
if OPENAI_CONTEXT_WINDOW_ERROR_SUBSTRING in error_message:
raise LLMError(error_message)
except LLMError:
raise
except:
print(f"Failed to parse SSE message, throwing SSE HTTP error up the stack")
event_source.response.raise_for_status()
try:
for sse in event_source.iter_sse():
# printd(sse.event, sse.data, sse.id, sse.retry)
if sse.data == OPENAI_SSE_DONE:
# print("finished")
break
else:
chunk_data = json.loads(sse.data)
# print("chunk_data::", chunk_data)
chunk_object = ChatCompletionChunkResponse(**chunk_data)
# print("chunk_object::", chunk_object)
# id=chunk_data["id"],
# choices=[ChunkChoice],
# model=chunk_data["model"],
# system_fingerprint=chunk_data["system_fingerprint"]
# )
yield chunk_object
except SSEError as e:
print("Caught an error while iterating the SSE stream:", str(e))
if "application/json" in str(e): # Check if the error is because of JSON response
# TODO figure out a better way to catch the error other than re-trying with a POST
response = client.post(url=url, json=data, headers=headers) # Make the request again to get the JSON response
if response.headers["Content-Type"].startswith("application/json"):
error_details = response.json() # Parse the JSON to get the error message
print("Request:", vars(response.request))
print("POST Error:", error_details)
print("Original SSE Error:", str(e))
else:
print("Failed to retrieve JSON error message via retry.")
else:
print("SSEError not related to 'application/json' content type.")
# Optionally re-raise the exception if you need to propagate it
raise e
except Exception as e:
if event_source.response.request is not None:
print("HTTP Request:", vars(event_source.response.request))
if event_source.response is not None:
print("HTTP Status:", event_source.response.status_code)
print("HTTP Headers:", event_source.response.headers)
# print("HTTP Body:", event_source.response.text)
print("Exception message:", str(e))
raise e
def openai_chat_completions_request_stream(
url: str,
api_key: str,
chat_completion_request: ChatCompletionRequest,
) -> Generator[ChatCompletionChunkResponse, None, None]:
from letta.utils import printd
url = smart_urljoin(url, "chat/completions")
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
data = chat_completion_request.model_dump(exclude_none=True)
printd("Request:\n", json.dumps(data, indent=2))
# If functions == None, strip from the payload
if "functions" in data and data["functions"] is None:
data.pop("functions")
data.pop("function_call", None) # extra safe, should exist always (default="auto")
if "tools" in data and data["tools"] is None:
data.pop("tools")
data.pop("tool_choice", None) # extra safe, should exist always (default="auto")
if "tools" in data:
for tool in data["tools"]:
# tool["strict"] = True
try:
tool["function"] = convert_to_structured_output(tool["function"])
except ValueError as e:
warnings.warn(f"Failed to convert tool function to structured output, tool={tool}, error={e}")
# print(f"\n\n\n\nData[tools]: {json.dumps(data['tools'], indent=2)}")
printd(f"Sending request to {url}")
try:
return _sse_post(url=url, data=data, headers=headers)
except requests.exceptions.HTTPError as http_err:
# Handle HTTP errors (e.g., response 4XX, 5XX)
printd(f"Got HTTPError, exception={http_err}, payload={data}")
raise http_err
except requests.exceptions.RequestException as req_err:
# Handle other requests-related errors (e.g., connection error)
printd(f"Got RequestException, exception={req_err}")
raise req_err
except Exception as e:
# Handle other potential errors
printd(f"Got unknown Exception, exception={e}")
raise e
data = prepare_openai_payload(chat_completion_request)
data["stream"] = True
client = OpenAI(
api_key=api_key,
base_url=url,
)
stream = client.chat.completions.create(**data)
for chunk in stream:
# TODO: Use the native OpenAI objects here?
yield ChatCompletionChunkResponse(**chunk.model_dump(exclude_none=True))
def openai_chat_completions_request(
@ -512,18 +404,28 @@ def openai_chat_completions_request(
https://platform.openai.com/docs/guides/text-generation?lang=curl
"""
from letta.utils import printd
data = prepare_openai_payload(chat_completion_request)
client = OpenAI(api_key=api_key, base_url=url)
chat_completion = client.chat.completions.create(**data)
return ChatCompletionResponse(**chat_completion.model_dump())
url = smart_urljoin(url, "chat/completions")
def openai_embeddings_request(url: str, api_key: str, data: dict) -> EmbeddingResponse:
"""https://platform.openai.com/docs/api-reference/embeddings/create"""
url = smart_urljoin(url, "embeddings")
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
response_json = make_post_request(url, headers, data)
return EmbeddingResponse(**response_json)
def prepare_openai_payload(chat_completion_request: ChatCompletionRequest):
data = chat_completion_request.model_dump(exclude_none=True)
# add check otherwise will cause error: "Invalid value for 'parallel_tool_calls': 'parallel_tool_calls' is only allowed when 'tools' are specified."
if chat_completion_request.tools is not None:
data["parallel_tool_calls"] = False
printd("Request:\n", json.dumps(data, indent=2))
# If functions == None, strip from the payload
if "functions" in data and data["functions"] is None:
data.pop("functions")
@ -540,14 +442,4 @@ def openai_chat_completions_request(
except ValueError as e:
warnings.warn(f"Failed to convert tool function to structured output, tool={tool}, error={e}")
response_json = make_post_request(url, headers, data)
return ChatCompletionResponse(**response_json)
def openai_embeddings_request(url: str, api_key: str, data: dict) -> EmbeddingResponse:
"""https://platform.openai.com/docs/api-reference/embeddings/create"""
url = smart_urljoin(url, "embeddings")
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
response_json = make_post_request(url, headers, data)
return EmbeddingResponse(**response_json)
return data

View File

@ -6,7 +6,7 @@ from letta.prompts.gpt_summarize import SYSTEM as SUMMARY_PROMPT_SYSTEM
from letta.schemas.agent import AgentState
from letta.schemas.enums import MessageRole
from letta.schemas.memory import Memory
from letta.schemas.message import Message
from letta.schemas.message import Message, TextContent
from letta.settings import summarizer_settings
from letta.utils import count_tokens, printd
@ -60,9 +60,9 @@ def summarize_messages(
dummy_agent_id = agent_state.id
message_sequence = [
Message(agent_id=dummy_agent_id, role=MessageRole.system, text=summary_prompt),
Message(agent_id=dummy_agent_id, role=MessageRole.assistant, text=MESSAGE_SUMMARY_REQUEST_ACK),
Message(agent_id=dummy_agent_id, role=MessageRole.user, text=summary_input),
Message(agent_id=dummy_agent_id, role=MessageRole.system, content=[TextContent(text=summary_prompt)]),
Message(agent_id=dummy_agent_id, role=MessageRole.assistant, content=[TextContent(text=MESSAGE_SUMMARY_REQUEST_ACK)]),
Message(agent_id=dummy_agent_id, role=MessageRole.user, content=[TextContent(text=summary_input)]),
]
# TODO: We need to eventually have a separate LLM config for the summarizer LLM

View File

@ -8,12 +8,12 @@ from letta.schemas.openai.chat_completion_response import UsageStatistics
from letta.schemas.usage import LettaUsageStatistics
def trigger_rethink_memory(agent_state: "AgentState", message: Optional[str]) -> Optional[str]: # type: ignore
def trigger_rethink_memory(agent_state: "AgentState", message: str) -> None: # type: ignore
"""
Called if and only when user says the word trigger_rethink_memory". It will trigger the re-evaluation of the memory.
Args:
message (Optional[str]): Description of what aspect of the memory should be re-evaluated.
message (str): Description of what aspect of the memory should be re-evaluated.
"""
from letta import create_client
@ -25,12 +25,12 @@ def trigger_rethink_memory(agent_state: "AgentState", message: Optional[str]) ->
client.user_message(agent_id=agent.id, message=message)
def trigger_rethink_memory_convo(agent_state: "AgentState", message: Optional[str]) -> Optional[str]: # type: ignore
def trigger_rethink_memory_convo(agent_state: "AgentState", message: str) -> None: # type: ignore
"""
Called if and only when user says the word "trigger_rethink_memory". It will trigger the re-evaluation of the memory.
Args:
message (Optional[str]): Description of what aspect of the memory should be re-evaluated.
message (str): Description of what aspect of the memory should be re-evaluated.
"""
from letta import create_client
@ -48,7 +48,7 @@ def trigger_rethink_memory_convo(agent_state: "AgentState", message: Optional[st
client.user_message(agent_id=agent.id, message=message)
def rethink_memory_convo(agent_state: "AgentState", new_memory: str, target_block_label: Optional[str], source_block_label: Optional[str]) -> Optional[str]: # type: ignore
def rethink_memory_convo(agent_state: "AgentState", new_memory: str, target_block_label: str, source_block_label: str) -> None: # type: ignore
"""
Re-evaluate the memory in block_name, integrating new and updated facts. Replace outdated information with the most likely truths, avoiding redundancy with original memories. Ensure consistency with other memory blocks.
@ -58,7 +58,7 @@ def rethink_memory_convo(agent_state: "AgentState", new_memory: str, target_bloc
target_block_label (str): The name of the block to write to. This should be chat_agent_human_new or chat_agent_persona_new.
Returns:
Optional[str]: None is always returned as this function does not produce a response.
None: None is always returned as this function does not produce a response.
"""
if target_block_label is not None:
if agent_state.memory.get_block(target_block_label) is None:
@ -67,7 +67,7 @@ def rethink_memory_convo(agent_state: "AgentState", new_memory: str, target_bloc
return None
def rethink_memory(agent_state: "AgentState", new_memory: str, target_block_label: Optional[str], source_block_label: Optional[str]) -> Optional[str]: # type: ignore
def rethink_memory(agent_state: "AgentState", new_memory: str, target_block_label: str, source_block_label: str) -> None: # type: ignore
"""
Re-evaluate the memory in block_name, integrating new and updated facts.
Replace outdated information with the most likely truths, avoiding redundancy with original memories.
@ -78,7 +78,7 @@ def rethink_memory(agent_state: "AgentState", new_memory: str, target_block_labe
source_block_label (str): The name of the block to integrate information from. None if all the information has been integrated to terminate the loop.
target_block_label (str): The name of the block to write to.
Returns:
Optional[str]: None is always returned as this function does not produce a response.
None: None is always returned as this function does not produce a response.
"""
if target_block_label is not None:
@ -88,7 +88,7 @@ def rethink_memory(agent_state: "AgentState", new_memory: str, target_block_labe
return None
def finish_rethinking_memory(agent_state: "AgentState") -> Optional[str]: # type: ignore
def finish_rethinking_memory(agent_state: "AgentState") -> None: # type: ignore
"""
This function is called when the agent is done rethinking the memory.
@ -98,7 +98,7 @@ def finish_rethinking_memory(agent_state: "AgentState") -> Optional[str]: # typ
return None
def finish_rethinking_memory_convo(agent_state: "AgentState") -> Optional[str]: # type: ignore
def finish_rethinking_memory_convo(agent_state: "AgentState") -> None: # type: ignore
"""
This function is called when the agent is done rethinking the memory.

View File

@ -1,7 +1,7 @@
import uuid
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import JSON, String
from sqlalchemy import JSON, Index, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from letta.orm.block import Block
@ -27,6 +27,7 @@ if TYPE_CHECKING:
class Agent(SqlalchemyBase, OrganizationMixin):
__tablename__ = "agents"
__pydantic_model__ = PydanticAgentState
__table_args__ = (Index("ix_agents_created_at", "created_at", "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
@ -69,7 +70,14 @@ class Agent(SqlalchemyBase, OrganizationMixin):
)
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")
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.",
)
messages: Mapped[List["Message"]] = relationship(
"Message",
back_populates="agent",

View File

@ -1,6 +1,6 @@
from typing import TYPE_CHECKING, Optional, Type
from typing import TYPE_CHECKING, List, Optional, Type
from sqlalchemy import JSON, BigInteger, Integer, UniqueConstraint, event
from sqlalchemy import JSON, BigInteger, Index, Integer, UniqueConstraint, event
from sqlalchemy.orm import Mapped, attributes, mapped_column, relationship
from letta.constants import CORE_MEMORY_BLOCK_CHAR_LIMIT
@ -20,7 +20,10 @@ class Block(OrganizationMixin, SqlalchemyBase):
__tablename__ = "block"
__pydantic_model__ = PydanticBlock
# This may seem redundant, but is necessary for the BlocksAgents composite FK relationship
__table_args__ = (UniqueConstraint("id", "label", name="unique_block_id_label"),)
__table_args__ = (
UniqueConstraint("id", "label", name="unique_block_id_label"),
Index("created_at_label_idx", "created_at", "label"),
)
template_name: Mapped[Optional[str]] = mapped_column(
nullable=True, doc="the unique name that identifies a block in a human-readable way"
@ -36,6 +39,14 @@ class Block(OrganizationMixin, SqlalchemyBase):
# relationships
organization: Mapped[Optional["Organization"]] = relationship("Organization")
agents: Mapped[List["Agent"]] = relationship(
"Agent",
secondary="blocks_agents",
lazy="selectin",
passive_deletes=True, # Ensures SQLAlchemy doesn't fetch blocks_agents rows before deleting
back_populates="core_memory",
doc="Agents associated with this block.",
)
def to_pydantic(self) -> Type:
match self.label:

View File

@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import JSON, String
from sqlalchemy import JSON, Index, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from letta.orm.enums import JobType
@ -25,6 +25,7 @@ class Job(SqlalchemyBase, UserMixin):
__tablename__ = "jobs"
__pydantic_model__ = PydanticJob
__table_args__ = (Index("ix_jobs_created_at", "created_at", "id"),)
status: Mapped[JobStatus] = mapped_column(String, default=JobStatus.created, doc="The current status of the job.")
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, doc="The unix timestamp of when the job was completed.")

View File

@ -1,30 +0,0 @@
from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from letta.orm.sqlalchemy_base import SqlalchemyBase
if TYPE_CHECKING:
from letta.orm.job import Job
class JobUsageStatistics(SqlalchemyBase):
"""Tracks usage statistics for jobs, with future support for per-step tracking."""
__tablename__ = "job_usage_statistics"
id: Mapped[int] = mapped_column(primary_key=True, doc="Unique identifier for the usage statistics entry")
job_id: Mapped[str] = mapped_column(
ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False, doc="ID of the job these statistics belong to"
)
step_id: Mapped[Optional[str]] = mapped_column(
nullable=True, doc="ID of the specific step within the job (for future per-step tracking)"
)
completion_tokens: Mapped[int] = mapped_column(default=0, doc="Number of tokens generated by the agent")
prompt_tokens: Mapped[int] = mapped_column(default=0, doc="Number of tokens in the prompt")
total_tokens: Mapped[int] = mapped_column(default=0, doc="Total number of tokens processed by the agent")
step_count: Mapped[int] = mapped_column(default=0, doc="Number of steps taken by the agent")
# Relationship back to the job
job: Mapped["Job"] = relationship("Job", back_populates="usage_statistics")

View File

@ -8,13 +8,17 @@ from letta.orm.custom_columns import ToolCallColumn
from letta.orm.mixins import AgentMixin, OrganizationMixin
from letta.orm.sqlalchemy_base import SqlalchemyBase
from letta.schemas.message import Message as PydanticMessage
from letta.schemas.message import TextContent as PydanticTextContent
class Message(SqlalchemyBase, OrganizationMixin, AgentMixin):
"""Defines data model for storing Message objects"""
__tablename__ = "messages"
__table_args__ = (Index("ix_messages_agent_created_at", "agent_id", "created_at"),)
__table_args__ = (
Index("ix_messages_agent_created_at", "agent_id", "created_at"),
Index("ix_messages_created_at", "created_at", "id"),
)
__pydantic_model__ = PydanticMessage
id: Mapped[str] = mapped_column(primary_key=True, doc="Unique message identifier")
@ -42,3 +46,10 @@ class Message(SqlalchemyBase, OrganizationMixin, AgentMixin):
def job(self) -> Optional["Job"]:
"""Get the job associated with this message, if any."""
return self.job_message.job if self.job_message else None
def to_pydantic(self) -> PydanticMessage:
"""custom pydantic conversion for message content mapping"""
model = self.__pydantic_model__.model_validate(self)
if self.text:
model.content = [PydanticTextContent(text=self.text)]
return model

View File

@ -45,8 +45,12 @@ class BasePassage(SqlalchemyBase, OrganizationMixin):
@declared_attr
def __table_args__(cls):
if settings.letta_pg_uri_no_default:
return (Index(f"{cls.__tablename__}_org_idx", "organization_id"), {"extend_existing": True})
return ({"extend_existing": True},)
return (
Index(f"{cls.__tablename__}_org_idx", "organization_id"),
Index(f"{cls.__tablename__}_created_at_id_idx", "created_at", "id"),
{"extend_existing": True},
)
return (Index(f"{cls.__tablename__}_created_at_id_idx", "created_at", "id"), {"extend_existing": True})
class SourcePassage(BasePassage, FileMixin, SourceMixin):

View File

@ -1,6 +1,6 @@
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import JSON
from sqlalchemy import JSON, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from letta.orm import FileMetadata
@ -23,6 +23,11 @@ class Source(SqlalchemyBase, OrganizationMixin):
__tablename__ = "sources"
__pydantic_model__ = PydanticSource
__table_args__ = (
Index(f"source_created_at_id_idx", "created_at", "id"),
{"extend_existing": True},
)
name: Mapped[str] = mapped_column(doc="the name of the source, must be unique within the org", nullable=False)
description: Mapped[str] = mapped_column(nullable=True, doc="a human-readable description of the source")
embedding_config: Mapped[EmbeddingConfig] = mapped_column(EmbeddingConfigColumn, doc="Configuration settings for embedding.")

View File

@ -3,7 +3,7 @@ from enum import Enum
from functools import wraps
from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union
from sqlalchemy import String, and_, desc, func, or_, select
from sqlalchemy import String, and_, func, or_, select
from sqlalchemy.exc import DBAPIError, IntegrityError, TimeoutError
from sqlalchemy.orm import Mapped, Session, mapped_column
@ -52,7 +52,8 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
cls,
*,
db_session: "Session",
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: Optional[int] = 50,
@ -69,12 +70,13 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
**kwargs,
) -> List["SqlalchemyBase"]:
"""
List records with cursor-based pagination, ordering by created_at.
Cursor is an ID, but pagination is based on the cursor object's created_at value.
List records with before/after pagination, ordering by created_at.
Can use both before and after to fetch a window of records.
Args:
db_session: SQLAlchemy session
cursor: ID of the last item seen (for pagination)
before: ID of item to paginate before (upper bound)
after: ID of item to paginate after (lower bound)
start_date: Filter items after this date
end_date: Filter items before this date
limit: Maximum number of items to return
@ -89,13 +91,25 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
raise ValueError("start_date must be earlier than or equal to end_date")
logger.debug(f"Listing {cls.__name__} with kwarg filters {kwargs}")
with db_session as session:
# If cursor provided, get the reference object
cursor_obj = None
if cursor:
cursor_obj = session.get(cls, cursor)
if not cursor_obj:
raise NoResultFound(f"No {cls.__name__} found with id {cursor}")
# Get the reference objects for pagination
before_obj = None
after_obj = None
if before:
before_obj = session.get(cls, before)
if not before_obj:
raise NoResultFound(f"No {cls.__name__} found with id {before}")
if after:
after_obj = session.get(cls, after)
if not after_obj:
raise NoResultFound(f"No {cls.__name__} found with id {after}")
# Validate that before comes after the after object if both are provided
if before_obj and after_obj and before_obj.created_at < after_obj.created_at:
raise ValueError("'before' reference must be later than 'after' reference")
query = select(cls)
@ -122,8 +136,8 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
else:
# Match ANY tag - use join and filter
query = (
query.join(cls.tags).filter(cls.tags.property.mapper.class_.tag.in_(tags)).group_by(cls.id) # Deduplicate results
)
query.join(cls.tags).filter(cls.tags.property.mapper.class_.tag.in_(tags)).group_by(cls.id)
) # Deduplicate results
# Group by primary key and all necessary columns to avoid JSON comparison
query = query.group_by(cls.id)
@ -150,16 +164,35 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
if end_date:
query = query.filter(cls.created_at < end_date)
# Cursor-based pagination
if cursor_obj:
if ascending:
query = query.where(cls.created_at >= cursor_obj.created_at).where(
or_(cls.created_at > cursor_obj.created_at, cls.id > cursor_obj.id)
)
# Handle pagination based on before/after
if before or after:
conditions = []
if before and after:
# Window-based query - get records between before and after
conditions = [
or_(cls.created_at < before_obj.created_at, and_(cls.created_at == before_obj.created_at, cls.id < before_obj.id)),
or_(cls.created_at > after_obj.created_at, and_(cls.created_at == after_obj.created_at, cls.id > after_obj.id)),
]
else:
query = query.where(cls.created_at <= cursor_obj.created_at).where(
or_(cls.created_at < cursor_obj.created_at, cls.id < cursor_obj.id)
)
# Pure pagination query
if before:
conditions.append(
or_(
cls.created_at < before_obj.created_at,
and_(cls.created_at == before_obj.created_at, cls.id < before_obj.id),
)
)
if after:
conditions.append(
or_(
cls.created_at > after_obj.created_at,
and_(cls.created_at == after_obj.created_at, cls.id > after_obj.id),
)
)
if conditions:
query = query.where(and_(*conditions))
# Text search
if query_text:
@ -184,7 +217,9 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
# SQLite with custom vector type
query_embedding_binary = adapt_array(query_embedding)
query = query.order_by(
func.cosine_distance(cls.embedding, query_embedding_binary).asc(), cls.created_at.asc(), cls.id.asc()
func.cosine_distance(cls.embedding, query_embedding_binary).asc(),
cls.created_at.asc() if ascending else cls.created_at.desc(),
cls.id.asc(),
)
is_ordered = True
@ -195,13 +230,28 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
# Apply ordering
if not is_ordered:
if ascending:
query = query.order_by(cls.created_at, cls.id)
query = query.order_by(cls.created_at.asc(), cls.id.asc())
else:
query = query.order_by(desc(cls.created_at), desc(cls.id))
query = query.order_by(cls.created_at.desc(), cls.id.desc())
query = query.limit(limit)
# Apply limit, adjusting for both bounds if necessary
if before and after:
# When both bounds are provided, we need to fetch enough records to satisfy
# the limit while respecting both bounds. We'll fetch more and then trim.
query = query.limit(limit * 2)
else:
query = query.limit(limit)
return list(session.execute(query).scalars())
results = list(session.execute(query).scalars())
# If we have both bounds, take the middle portion
if before and after and len(results) > limit:
middle = len(results) // 2
start = max(0, middle - limit // 2)
end = min(len(results), start + limit)
results = results[start:end]
return results
@classmethod
@handle_db_timeout
@ -449,12 +499,10 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
def to_pydantic(self) -> "BaseModel":
"""converts to the basic pydantic model counterpart"""
model = self.__pydantic_model__.model_validate(self)
if hasattr(self, "metadata_"):
model_dict = {k: v for k, v in self.__dict__.items() if k in self.__pydantic_model__.model_fields}
model_dict["metadata"] = self.metadata_
return self.__pydantic_model__.model_validate(model_dict)
return self.__pydantic_model__.model_validate(self)
model.metadata = self.metadata_
return model
def to_record(self) -> "BaseModel":
"""Deprecated accessor for to_pydantic"""

View File

@ -1,6 +1,6 @@
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import JSON, String, UniqueConstraint
from sqlalchemy import JSON, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
# TODO everything in functions should live in this model
@ -26,7 +26,10 @@ class Tool(SqlalchemyBase, OrganizationMixin):
# Add unique constraint on (name, _organization_id)
# An organization should not have multiple tools with the same name
__table_args__ = (UniqueConstraint("name", "organization_id", name="uix_name_organization"),)
__table_args__ = (
UniqueConstraint("name", "organization_id", name="uix_name_organization"),
Index("ix_tools_created_at_name", "created_at", "name"),
)
name: Mapped[str] = mapped_column(doc="The display name of the tool.")
tool_type: Mapped[ToolType] = mapped_column(

View File

@ -0,0 +1,3 @@
from typing import Dict
EMBEDDING_HANDLE_OVERRIDES: Dict[str, Dict[str, str]] = {}

View File

@ -9,6 +9,10 @@ class MessageRole(str, Enum):
system = "system"
class MessageContentType(str, Enum):
text = "text"
class OptionState(str, Enum):
"""Useful for kwargs that are bool + default option"""

View File

@ -12,7 +12,7 @@ class JobBase(OrmMetadataBase):
__id_prefix__ = "job"
status: JobStatus = Field(default=JobStatus.created, description="The status of the job.")
completed_at: Optional[datetime] = Field(None, description="The unix timestamp of when the job was completed.")
metadata: Optional[dict] = Field(None, description="The metadata of the job.")
metadata: Optional[dict] = Field(None, validation_alias="metadata_", description="The metadata of the job.")
job_type: JobType = Field(default=JobType.JOB, description="The type of the job.")

View File

@ -4,6 +4,8 @@ from typing import Annotated, List, Literal, Optional, Union
from pydantic import BaseModel, Field, field_serializer, field_validator
from letta.schemas.enums import MessageContentType
# Letta API style responses (intended to be easier to use vs getting true Message types)
@ -32,18 +34,33 @@ class LettaMessage(BaseModel):
return dt.isoformat(timespec="seconds")
class MessageContent(BaseModel):
type: MessageContentType = Field(..., description="The type of the message.")
class TextContent(MessageContent):
type: Literal[MessageContentType.text] = Field(MessageContentType.text, description="The type of the message.")
text: str = Field(..., description="The text content of the message.")
MessageContentUnion = Annotated[
Union[TextContent],
Field(discriminator="type"),
]
class SystemMessage(LettaMessage):
"""
A message generated by the system. Never streamed back on a response, only used for cursor pagination.
Attributes:
message (str): The message sent by the system
content (Union[str, List[MessageContentUnion]]): The message content sent by the user (can be a string or an array of content parts)
id (str): The ID of the message
date (datetime): The date the message was created in ISO format
"""
message_type: Literal["system_message"] = "system_message"
message: str
content: Union[str, List[MessageContentUnion]]
class UserMessage(LettaMessage):
@ -51,13 +68,13 @@ class UserMessage(LettaMessage):
A message sent by the user. Never streamed back on a response, only used for cursor pagination.
Attributes:
message (str): The message sent by the user
content (Union[str, List[MessageContentUnion]]): The message content sent by the user (can be a string or an array of content parts)
id (str): The ID of the message
date (datetime): The date the message was created in ISO format
"""
message_type: Literal["user_message"] = "user_message"
message: str
content: Union[str, List[MessageContentUnion]]
class ReasoningMessage(LettaMessage):
@ -167,7 +184,7 @@ class ToolReturnMessage(LettaMessage):
class AssistantMessage(LettaMessage):
message_type: Literal["assistant_message"] = "assistant_message"
assistant_message: str
content: Union[str, List[MessageContentUnion]]
class LegacyFunctionCallMessage(LettaMessage):

View File

@ -14,6 +14,7 @@ class LLMConfig(BaseModel):
model_wrapper (str): The wrapper for the model. This is used to wrap additional text around the input/output of the model. This is useful for text-to-text completions, such as the Completions API in OpenAI.
context_window (int): The context window size for the model.
put_inner_thoughts_in_kwargs (bool): Puts `inner_thoughts` as a kwarg in the function call if this is set to True. This helps with function calling performance and also the generation of inner thoughts.
temperature (float): The temperature to use when generating text with the model. A higher temperature will result in more random text.
"""
# TODO: 🤮 don't default to a vendor! bug city!
@ -46,6 +47,10 @@ class LLMConfig(BaseModel):
description="Puts 'inner_thoughts' as a kwarg in the function call if this is set to True. This helps with function calling performance and also the generation of inner thoughts.",
)
handle: Optional[str] = Field(None, description="The handle for this config, in the format provider/model-name.")
temperature: float = Field(
0.7,
description="The temperature to use when generating text with the model. A higher temperature will result in more random text.",
)
# FIXME hack to silence pydantic protected namespace warning
model_config = ConfigDict(protected_namespaces=())

View File

@ -0,0 +1,38 @@
from typing import Dict
LLM_HANDLE_OVERRIDES: Dict[str, Dict[str, str]] = {
"anthropic": {
"claude-3-5-haiku-20241022": "claude-3.5-haiku",
"claude-3-5-sonnet-20241022": "claude-3.5-sonnet",
"claude-3-opus-20240229": "claude-3-opus",
},
"openai": {
"chatgpt-4o-latest": "chatgpt-4o",
"gpt-3.5-turbo": "gpt-3.5-turbo",
"gpt-3.5-turbo-0125": "gpt-3.5-turbo-jan",
"gpt-3.5-turbo-1106": "gpt-3.5-turbo-nov",
"gpt-3.5-turbo-16k": "gpt-3.5-turbo-16k",
"gpt-3.5-turbo-instruct": "gpt-3.5-turbo-instruct",
"gpt-4-0125-preview": "gpt-4-preview-jan",
"gpt-4-0613": "gpt-4-june",
"gpt-4-1106-preview": "gpt-4-preview-nov",
"gpt-4-turbo-2024-04-09": "gpt-4-turbo-apr",
"gpt-4o-2024-05-13": "gpt-4o-may",
"gpt-4o-2024-08-06": "gpt-4o-aug",
"gpt-4o-mini-2024-07-18": "gpt-4o-mini-jul",
},
"together": {
"Qwen/Qwen2.5-72B-Instruct-Turbo": "qwen-2.5-72b-instruct",
"meta-llama/Llama-3-70b-chat-hf": "llama-3-70b",
"meta-llama/Meta-Llama-3-70B-Instruct-Turbo": "llama-3-70b-instruct",
"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": "llama-3.1-405b-instruct",
"meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": "llama-3.1-70b-instruct",
"meta-llama/Llama-3.3-70B-Instruct-Turbo": "llama-3.3-70b-instruct",
"mistralai/Mistral-7B-Instruct-v0.2": "mistral-7b-instruct-v2",
"mistralai/Mistral-7B-Instruct-v0.3": "mistral-7b-instruct-v3",
"mistralai/Mixtral-8x22B-Instruct-v0.1": "mixtral-8x22b-instruct",
"mistralai/Mixtral-8x7B-Instruct-v0.1": "mixtral-8x7b-instruct",
"mistralai/Mixtral-8x7B-v0.1": "mixtral-8x7b",
"NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO": "hermes-2-mixtral",
},
}

View File

@ -2,21 +2,23 @@ import copy
import json
import warnings
from datetime import datetime, timezone
from typing import List, Literal, Optional
from typing import Any, Dict, List, Literal, Optional, Union
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall
from openai.types.chat.chat_completion_message_tool_call import Function as OpenAIFunction
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG, TOOL_CALL_ID_MAX_LEN
from letta.local_llm.constants import INNER_THOUGHTS_KWARG
from letta.schemas.enums import MessageRole
from letta.schemas.enums import MessageContentType, MessageRole
from letta.schemas.letta_base import OrmMetadataBase
from letta.schemas.letta_message import (
AssistantMessage,
LettaMessage,
MessageContentUnion,
ReasoningMessage,
SystemMessage,
TextContent,
ToolCall,
ToolCallMessage,
ToolReturnMessage,
@ -59,7 +61,7 @@ class MessageCreate(BaseModel):
MessageRole.user,
MessageRole.system,
] = Field(..., description="The role of the participant.")
text: str = Field(..., description="The text of the message.")
content: Union[str, List[MessageContentUnion]] = Field(..., description="The content of the message.")
name: Optional[str] = Field(None, description="The name of the participant.")
@ -67,7 +69,7 @@ class MessageUpdate(BaseModel):
"""Request to update a message"""
role: Optional[MessageRole] = Field(None, description="The role of the participant.")
text: Optional[str] = Field(None, description="The text of the message.")
content: Optional[Union[str, List[MessageContentUnion]]] = Field(..., description="The content of the message.")
# NOTE: probably doesn't make sense to allow remapping user_id or agent_id (vs creating a new message)
# user_id: Optional[str] = Field(None, description="The unique identifier of the user.")
# agent_id: Optional[str] = Field(None, description="The unique identifier of the agent.")
@ -79,6 +81,18 @@ class MessageUpdate(BaseModel):
tool_calls: Optional[List[OpenAIToolCall,]] = Field(None, description="The list of tool calls requested.")
tool_call_id: Optional[str] = Field(None, description="The id of the tool call.")
def model_dump(self, to_orm: bool = False, **kwargs) -> Dict[str, Any]:
data = super().model_dump(**kwargs)
if to_orm and "content" in data:
if isinstance(data["content"], str):
data["text"] = data["content"]
else:
for content in data["content"]:
if content["type"] == "text":
data["text"] = content["text"]
del data["content"]
return data
class Message(BaseMessage):
"""
@ -100,7 +114,7 @@ class Message(BaseMessage):
id: str = BaseMessage.generate_id_field()
role: MessageRole = Field(..., description="The role of the participant.")
text: Optional[str] = Field(None, description="The text of the message.")
content: Optional[List[MessageContentUnion]] = Field(None, description="The content of the message.")
organization_id: Optional[str] = Field(None, description="The unique identifier of the organization.")
agent_id: Optional[str] = Field(None, description="The unique identifier of the agent.")
model: Optional[str] = Field(None, description="The model used to make the function call.")
@ -108,6 +122,7 @@ class Message(BaseMessage):
tool_calls: Optional[List[OpenAIToolCall,]] = Field(None, description="The list of tool calls requested.")
tool_call_id: Optional[str] = Field(None, description="The id of the tool call.")
step_id: Optional[str] = Field(None, description="The id of the step that this message was created in.")
# This overrides the optional base orm schema, created_at MUST exist on all messages objects
created_at: datetime = Field(default_factory=get_utc_time, description="The timestamp when the object was created.")
@ -118,6 +133,37 @@ class Message(BaseMessage):
assert v in roles, f"Role must be one of {roles}"
return v
@model_validator(mode="before")
@classmethod
def convert_from_orm(cls, data: Dict[str, Any]) -> Dict[str, Any]:
if isinstance(data, dict):
if "text" in data and "content" not in data:
data["content"] = [TextContent(text=data["text"])]
del data["text"]
return data
def model_dump(self, to_orm: bool = False, **kwargs) -> Dict[str, Any]:
data = super().model_dump(**kwargs)
if to_orm:
for content in data["content"]:
if content["type"] == "text":
data["text"] = content["text"]
del data["content"]
return data
@property
def text(self) -> Optional[str]:
"""
Retrieve the first text content's text.
Returns:
str: The text content, or None if no text content exists
"""
if not self.content:
return None
text_content = [content.text for content in self.content if content.type == MessageContentType.text]
return text_content[0] if text_content else None
def to_json(self):
json_message = vars(self)
if json_message["tool_calls"] is not None:
@ -165,7 +211,7 @@ class Message(BaseMessage):
AssistantMessage(
id=self.id,
date=self.created_at,
assistant_message=message_string,
content=message_string,
)
)
else:
@ -221,7 +267,7 @@ class Message(BaseMessage):
UserMessage(
id=self.id,
date=self.created_at,
message=self.text,
content=self.text,
)
)
elif self.role == MessageRole.system:
@ -231,7 +277,7 @@ class Message(BaseMessage):
SystemMessage(
id=self.id,
date=self.created_at,
message=self.text,
content=self.text,
)
)
else:
@ -283,7 +329,7 @@ class Message(BaseMessage):
model=model,
# standard fields expected in an OpenAI ChatCompletion message object
role=MessageRole.tool, # NOTE
text=openai_message_dict["content"],
content=[TextContent(text=openai_message_dict["content"])],
name=openai_message_dict["name"] if "name" in openai_message_dict else None,
tool_calls=openai_message_dict["tool_calls"] if "tool_calls" in openai_message_dict else None,
tool_call_id=openai_message_dict["tool_call_id"] if "tool_call_id" in openai_message_dict else None,
@ -296,7 +342,7 @@ class Message(BaseMessage):
model=model,
# standard fields expected in an OpenAI ChatCompletion message object
role=MessageRole.tool, # NOTE
text=openai_message_dict["content"],
content=[TextContent(text=openai_message_dict["content"])],
name=openai_message_dict["name"] if "name" in openai_message_dict else None,
tool_calls=openai_message_dict["tool_calls"] if "tool_calls" in openai_message_dict else None,
tool_call_id=openai_message_dict["tool_call_id"] if "tool_call_id" in openai_message_dict else None,
@ -328,7 +374,7 @@ class Message(BaseMessage):
model=model,
# standard fields expected in an OpenAI ChatCompletion message object
role=MessageRole(openai_message_dict["role"]),
text=openai_message_dict["content"],
content=[TextContent(text=openai_message_dict["content"])],
name=openai_message_dict["name"] if "name" in openai_message_dict else None,
tool_calls=tool_calls,
tool_call_id=None, # NOTE: None, since this field is only non-null for role=='tool'
@ -341,7 +387,7 @@ class Message(BaseMessage):
model=model,
# standard fields expected in an OpenAI ChatCompletion message object
role=MessageRole(openai_message_dict["role"]),
text=openai_message_dict["content"],
content=[TextContent(text=openai_message_dict["content"])],
name=openai_message_dict["name"] if "name" in openai_message_dict else None,
tool_calls=tool_calls,
tool_call_id=None, # NOTE: None, since this field is only non-null for role=='tool'
@ -373,7 +419,7 @@ class Message(BaseMessage):
model=model,
# standard fields expected in an OpenAI ChatCompletion message object
role=MessageRole(openai_message_dict["role"]),
text=openai_message_dict["content"],
content=[TextContent(text=openai_message_dict["content"])],
name=openai_message_dict["name"] if "name" in openai_message_dict else None,
tool_calls=tool_calls,
tool_call_id=openai_message_dict["tool_call_id"] if "tool_call_id" in openai_message_dict else None,
@ -386,7 +432,7 @@ class Message(BaseMessage):
model=model,
# standard fields expected in an OpenAI ChatCompletion message object
role=MessageRole(openai_message_dict["role"]),
text=openai_message_dict["content"],
content=[TextContent(text=openai_message_dict["content"] or "")],
name=openai_message_dict["name"] if "name" in openai_message_dict else None,
tool_calls=tool_calls,
tool_call_id=openai_message_dict["tool_call_id"] if "tool_call_id" in openai_message_dict else None,

View File

@ -104,7 +104,7 @@ class ChatCompletionRequest(BaseModel):
logit_bias: Optional[Dict[str, int]] = None
logprobs: Optional[bool] = False
top_logprobs: Optional[int] = None
max_tokens: Optional[int] = None
max_completion_tokens: Optional[int] = None
n: Optional[int] = 1
presence_penalty: Optional[float] = 0
response_format: Optional[ResponseFormat] = None

View File

@ -23,7 +23,7 @@ class PassageBase(OrmMetadataBase):
# file association
file_id: Optional[str] = Field(None, description="The unique identifier of the file associated with the passage.")
metadata: Optional[Dict] = Field({}, description="The metadata of the passage.")
metadata: Optional[Dict] = Field({}, validation_alias="metadata_", description="The metadata of the passage.")
class Passage(PassageBase):

View File

@ -7,8 +7,10 @@ from letta.constants import LLM_MAX_TOKENS, MIN_CONTEXT_WINDOW
from letta.llm_api.azure_openai import get_azure_chat_completions_endpoint, get_azure_embeddings_endpoint
from letta.llm_api.azure_openai_constants import AZURE_MODEL_TO_CONTEXT_LENGTH
from letta.schemas.embedding_config import EmbeddingConfig
from letta.schemas.embedding_config_overrides import EMBEDDING_HANDLE_OVERRIDES
from letta.schemas.letta_base import LettaBase
from letta.schemas.llm_config import LLMConfig
from letta.schemas.llm_config_overrides import LLM_HANDLE_OVERRIDES
class ProviderBase(LettaBase):
@ -39,7 +41,21 @@ class Provider(ProviderBase):
"""String representation of the provider for display purposes"""
raise NotImplementedError
def get_handle(self, model_name: str) -> str:
def get_handle(self, model_name: str, is_embedding: bool = False) -> str:
"""
Get the handle for a model, with support for custom overrides.
Args:
model_name (str): The name of the model.
is_embedding (bool, optional): Whether the handle is for an embedding model. Defaults to False.
Returns:
str: The handle for the model.
"""
overrides = EMBEDDING_HANDLE_OVERRIDES if is_embedding else LLM_HANDLE_OVERRIDES
if self.name in overrides and model_name in overrides[self.name]:
model_name = overrides[self.name][model_name]
return f"{self.name}/{model_name}"
@ -76,7 +92,7 @@ class LettaProvider(Provider):
embedding_endpoint="https://embeddings.memgpt.ai",
embedding_dim=1024,
embedding_chunk_size=300,
handle=self.get_handle("letta-free"),
handle=self.get_handle("letta-free", is_embedding=True),
)
]
@ -167,7 +183,7 @@ class OpenAIProvider(Provider):
embedding_endpoint="https://api.openai.com/v1",
embedding_dim=1536,
embedding_chunk_size=300,
handle=self.get_handle("text-embedding-ada-002"),
handle=self.get_handle("text-embedding-ada-002", is_embedding=True),
),
EmbeddingConfig(
embedding_model="text-embedding-3-small",
@ -175,7 +191,7 @@ class OpenAIProvider(Provider):
embedding_endpoint="https://api.openai.com/v1",
embedding_dim=2000,
embedding_chunk_size=300,
handle=self.get_handle("text-embedding-3-small"),
handle=self.get_handle("text-embedding-3-small", is_embedding=True),
),
EmbeddingConfig(
embedding_model="text-embedding-3-large",
@ -183,7 +199,7 @@ class OpenAIProvider(Provider):
embedding_endpoint="https://api.openai.com/v1",
embedding_dim=2000,
embedding_chunk_size=300,
handle=self.get_handle("text-embedding-3-large"),
handle=self.get_handle("text-embedding-3-large", is_embedding=True),
),
]
@ -377,7 +393,7 @@ class OllamaProvider(OpenAIProvider):
embedding_endpoint=self.base_url,
embedding_dim=embedding_dim,
embedding_chunk_size=300,
handle=self.get_handle(model["name"]),
handle=self.get_handle(model["name"], is_embedding=True),
)
)
return configs
@ -575,7 +591,7 @@ class GoogleAIProvider(Provider):
embedding_endpoint=self.base_url,
embedding_dim=768,
embedding_chunk_size=300, # NOTE: max is 2048
handle=self.get_handle(model),
handle=self.get_handle(model, is_embedding=True),
)
)
return configs
@ -641,7 +657,7 @@ class AzureProvider(Provider):
embedding_endpoint=model_endpoint,
embedding_dim=768,
embedding_chunk_size=300, # NOTE: max is 2048
handle=self.get_handle(model_name),
handle=self.get_handle(model_name, is_embedding=True),
)
)
return configs

View File

@ -33,7 +33,7 @@ class Source(BaseSource):
description: Optional[str] = Field(None, description="The description of the source.")
embedding_config: EmbeddingConfig = Field(..., description="The embedding configuration used by the source.")
organization_id: Optional[str] = Field(None, description="The ID of the organization that created the source.")
metadata: Optional[dict] = Field(None, description="Metadata associated with the source.")
metadata: Optional[dict] = Field(None, validation_alias="metadata_", description="Metadata associated with the source.")
# metadata fields
created_by_id: Optional[str] = Field(None, description="The id of the user that made this Tool.")

View File

@ -12,7 +12,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.cors import CORSMiddleware
from letta.__init__ import __version__
from letta.constants import ADMIN_PREFIX, API_PREFIX, OPENAI_API_PREFIX
from letta.constants import ADMIN_PREFIX, API_PREFIX
from letta.errors import BedrockPermissionError, LettaAgentNotFoundError, LettaUserNotFoundError
from letta.log import get_logger
from letta.orm.errors import DatabaseTimeoutError, ForeignKeyConstraintViolationError, NoResultFound, UniqueConstraintViolationError
@ -49,9 +49,12 @@ password = None
# #typer.secho(f"Generated admin server password for this session: {password}", fg=typer.colors.GREEN)
import logging
import platform
from fastapi import FastAPI
is_windows = platform.system() == "Windows"
log = logging.getLogger("uvicorn")
@ -285,8 +288,14 @@ def start_server(
ssl_certfile="certs/localhost.pem",
)
else:
print(f"▶ Server running at: http://{host or 'localhost'}:{port or REST_DEFAULT_PORT}")
print(f"▶ View using ADE at: https://app.letta.com/development-servers/local/dashboard\n")
if is_windows:
# Windows doesn't those the fancy unicode characters
print(f"Server running at: http://{host or 'localhost'}:{port or REST_DEFAULT_PORT}")
print(f"View using ADE at: https://app.letta.com/development-servers/local/dashboard\n")
else:
print(f"▶ Server running at: http://{host or 'localhost'}:{port or REST_DEFAULT_PORT}")
print(f"▶ View using ADE at: https://app.letta.com/development-servers/local/dashboard\n")
uvicorn.run(
app,
host=host or "localhost",

View File

@ -472,7 +472,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
processed_chunk = AssistantMessage(
id=message_id,
date=message_date,
assistant_message=cleaned_func_args,
content=cleaned_func_args,
)
# otherwise we just do a regular passthrough of a ToolCallDelta via a ToolCallMessage
@ -613,7 +613,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
processed_chunk = AssistantMessage(
id=message_id,
date=message_date,
assistant_message=combined_chunk,
content=combined_chunk,
)
# Store the ID of the tool call so allow skipping the corresponding response
if self.function_id_buffer:
@ -627,7 +627,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
processed_chunk = AssistantMessage(
id=message_id,
date=message_date,
assistant_message=updates_main_json,
content=updates_main_json,
)
# Store the ID of the tool call so allow skipping the corresponding response
if self.function_id_buffer:
@ -959,7 +959,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
processed_chunk = AssistantMessage(
id=msg_obj.id,
date=msg_obj.created_at,
assistant_message=func_args["message"],
content=func_args["message"],
)
self._push_to_buffer(processed_chunk)
except Exception as e:
@ -981,7 +981,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
processed_chunk = AssistantMessage(
id=msg_obj.id,
date=msg_obj.created_at,
assistant_message=func_args[self.assistant_message_tool_kwarg],
content=func_args[self.assistant_message_tool_kwarg],
)
# Store the ID of the tool call so allow skipping the corresponding response
self.prev_assistant_message_id = function_call.id
@ -1018,8 +1018,6 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
# new_message = {"function_return": msg, "status": "success"}
assert msg_obj.tool_call_id is not None
print(f"YYY printing the function call - {msg_obj.tool_call_id} == {self.prev_assistant_message_id} ???")
# Skip this is use_assistant_message is on
if self.use_assistant_message and msg_obj.tool_call_id == self.prev_assistant_message_id:
# Wipe the cache

View File

@ -43,7 +43,8 @@ def list_agents(
),
server: "SyncServer" = Depends(get_letta_server),
user_id: Optional[str] = Header(None, alias="user_id"),
cursor: Optional[str] = Query(None, description="Cursor for pagination"),
before: Optional[str] = Query(None, description="Cursor for pagination"),
after: Optional[str] = Query(None, description="Cursor for pagination"),
limit: Optional[int] = Query(None, description="Limit for pagination"),
query_text: Optional[str] = Query(None, description="Search agents by name"),
):
@ -66,7 +67,7 @@ def list_agents(
}
# Call list_agents with the dynamic kwargs
agents = server.agent_manager.list_agents(actor=actor, cursor=cursor, limit=limit, **kwargs)
agents = server.agent_manager.list_agents(actor=actor, before=before, after=after, limit=limit, **kwargs)
return agents
@ -347,14 +348,11 @@ def list_archival_memory(
"""
actor = server.user_manager.get_user_or_default(user_id=user_id)
# TODO need to add support for non-postgres here
# chroma will throw:
# raise ValueError("Cannot run get_all_cursor with chroma")
return server.get_agent_archival_cursor(
return server.get_agent_archival(
user_id=actor.id,
agent_id=agent_id,
cursor=after, # TODO: deleting before, after. is this expected?
after=after,
before=before,
limit=limit,
)
@ -429,7 +427,7 @@ def list_messages(
"""
actor = server.user_manager.get_user_or_default(user_id=user_id)
return server.get_agent_recall_cursor(
return server.get_agent_recall(
user_id=actor.id,
agent_id=agent_id,
before=before,
@ -560,9 +558,6 @@ async def process_message_background(
)
server.job_manager.update_job_by_id(job_id=job_id, job_update=job_update, actor=actor)
# Add job usage statistics
server.job_manager.add_job_usage(job_id=job_id, usage=result.usage, actor=actor)
except Exception as e:
# Update job status to failed
job_update = JobUpdate(

View File

@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, List, Optional
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
from letta.orm.errors import NoResultFound
from letta.schemas.agent import AgentState
from letta.schemas.block import Block, BlockUpdate, CreateBlock
from letta.server.rest_api.utils import get_letta_server
from letta.server.server import SyncServer
@ -73,3 +74,21 @@ def retrieve_block(
return block
except NoResultFound:
raise HTTPException(status_code=404, detail="Block not found")
@router.get("/{block_id}/agents", response_model=List[AgentState], operation_id="list_agents_for_block")
def list_agents_for_block(
block_id: str,
server: SyncServer = Depends(get_letta_server),
user_id: Optional[str] = Header(None, alias="user_id"),
):
"""
Retrieves all agents associated with the specified block.
Raises a 404 if the block does not exist.
"""
actor = server.user_manager.get_user_or_default(user_id=user_id)
try:
agents = server.block_manager.get_agents_for_block(block_id=block_id, actor=actor)
return agents
except NoResultFound:
raise HTTPException(status_code=404, detail=f"Block with id={block_id} not found")

View File

@ -14,7 +14,7 @@ router = APIRouter(prefix="/orgs", tags=["organization", "admin"])
@router.get("/", tags=["admin"], response_model=List[Organization], operation_id="list_orgs")
def get_all_orgs(
cursor: Optional[str] = Query(None),
after: Optional[str] = Query(None),
limit: Optional[int] = Query(50),
server: "SyncServer" = Depends(get_letta_server),
):
@ -22,7 +22,7 @@ def get_all_orgs(
Get a list of all orgs in the database
"""
try:
orgs = server.organization_manager.list_organizations(cursor=cursor, limit=limit)
orgs = server.organization_manager.list_organizations(after=after, limit=limit)
except HTTPException:
raise
except Exception as e:

View File

@ -13,7 +13,7 @@ router = APIRouter(prefix="/providers", tags=["providers"])
@router.get("/", tags=["providers"], response_model=List[Provider], operation_id="list_providers")
def list_providers(
cursor: Optional[str] = Query(None),
after: Optional[str] = Query(None),
limit: Optional[int] = Query(50),
server: "SyncServer" = Depends(get_letta_server),
):
@ -21,7 +21,7 @@ def list_providers(
Get a list of all custom providers in the database
"""
try:
providers = server.provider_manager.list_providers(cursor=cursor, limit=limit)
providers = server.provider_manager.list_providers(after=after, limit=limit)
except HTTPException:
raise
except Exception as e:

View File

@ -75,9 +75,12 @@ async def list_run_messages(
run_id: str,
server: "SyncServer" = Depends(get_letta_server),
user_id: Optional[str] = Header(None, alias="user_id"),
cursor: Optional[str] = Query(None, description="Cursor for pagination"),
before: Optional[str] = Query(None, description="Cursor for pagination"),
after: Optional[str] = Query(None, description="Cursor for pagination"),
limit: Optional[int] = Query(100, description="Maximum number of messages to return"),
ascending: bool = Query(True, description="Sort order by creation time"),
order: str = Query(
"desc", description="Sort order by the created_at timestamp of the objects. asc for ascending order and desc for descending order."
),
role: Optional[MessageRole] = Query(None, description="Filter by role"),
):
"""
@ -85,9 +88,10 @@ async def list_run_messages(
Args:
run_id: ID of the run
cursor: Cursor for pagination
before: A cursor for use in pagination. `before` is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, starting with obj_foo, your subsequent call can include before=obj_foo in order to fetch the previous page of the list.
after: A cursor for use in pagination. `after` is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with obj_foo, your subsequent call can include after=obj_foo in order to fetch the next page of the list.
limit: Maximum number of messages to return
ascending: Sort order by creation time
order: Sort order by the created_at timestamp of the objects. asc for ascending order and desc for descending order.
role: Filter by role (user/assistant/system/tool)
return_message_object: Whether to return Message objects or LettaMessage objects
user_id: ID of the user making the request
@ -95,15 +99,19 @@ async def list_run_messages(
Returns:
A list of messages associated with the run. Default is List[LettaMessage].
"""
if order not in ["asc", "desc"]:
raise HTTPException(status_code=400, detail="Order must be 'asc' or 'desc'")
actor = server.user_manager.get_user_or_default(user_id=user_id)
try:
messages = server.job_manager.get_run_messages_cursor(
messages = server.job_manager.get_run_messages(
run_id=run_id,
actor=actor,
limit=limit,
cursor=cursor,
ascending=ascending,
before=before,
after=after,
ascending=(order == "asc"),
role=role,
)
return messages

View File

@ -68,13 +68,13 @@ def delete_sandbox_config(
@router.get("/", response_model=List[PydanticSandboxConfig])
def list_sandbox_configs(
limit: int = Query(1000, description="Number of results to return"),
cursor: Optional[str] = Query(None, description="Pagination cursor to fetch the next set of results"),
after: Optional[str] = Query(None, description="Pagination cursor to fetch the next set of results"),
sandbox_type: Optional[SandboxType] = Query(None, description="Filter for this specific sandbox type"),
server: SyncServer = Depends(get_letta_server),
user_id: str = Depends(get_user_id),
):
actor = server.user_manager.get_user_or_default(user_id=user_id)
return server.sandbox_config_manager.list_sandbox_configs(actor, limit=limit, cursor=cursor, sandbox_type=sandbox_type)
return server.sandbox_config_manager.list_sandbox_configs(actor, limit=limit, after=after, sandbox_type=sandbox_type)
### Sandbox Environment Variable Routes
@ -116,9 +116,9 @@ def delete_sandbox_env_var(
def list_sandbox_env_vars(
sandbox_config_id: str,
limit: int = Query(1000, description="Number of results to return"),
cursor: Optional[str] = Query(None, description="Pagination cursor to fetch the next set of results"),
after: Optional[str] = Query(None, description="Pagination cursor to fetch the next set of results"),
server: SyncServer = Depends(get_letta_server),
user_id: str = Depends(get_user_id),
):
actor = server.user_manager.get_user_or_default(user_id=user_id)
return server.sandbox_config_manager.list_sandbox_env_vars(sandbox_config_id, actor, limit=limit, cursor=cursor)
return server.sandbox_config_manager.list_sandbox_env_vars(sandbox_config_id, actor, limit=limit, after=after)

View File

@ -165,7 +165,7 @@ def list_source_passages(
def list_source_files(
source_id: str,
limit: int = Query(1000, description="Number of files to return"),
cursor: Optional[str] = Query(None, description="Pagination cursor to fetch the next set of results"),
after: Optional[str] = Query(None, description="Pagination cursor to fetch the next set of results"),
server: "SyncServer" = Depends(get_letta_server),
user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
):
@ -173,7 +173,7 @@ def list_source_files(
List paginated files associated with a data source.
"""
actor = server.user_manager.get_user_or_default(user_id=user_id)
return server.source_manager.list_files(source_id=source_id, limit=limit, cursor=cursor, actor=actor)
return server.source_manager.list_files(source_id=source_id, limit=limit, after=after, actor=actor)
# it's redundant to include /delete in the URL path. The HTTP verb DELETE already implies that action.

View File

@ -13,7 +13,7 @@ router = APIRouter(prefix="/tags", tags=["tag", "admin"])
@router.get("/", tags=["admin"], response_model=List[str], operation_id="list_tags")
def list_tags(
cursor: Optional[str] = Query(None),
after: Optional[str] = Query(None),
limit: Optional[int] = Query(50),
server: "SyncServer" = Depends(get_letta_server),
query_text: Optional[str] = Query(None),
@ -23,5 +23,5 @@ def list_tags(
Get a list of all tags in the database
"""
actor = server.user_manager.get_user_or_default(user_id=user_id)
tags = server.agent_manager.list_tags(actor=actor, cursor=cursor, limit=limit, query_text=query_text)
tags = server.agent_manager.list_tags(actor=actor, after=after, limit=limit, query_text=query_text)
return tags

View File

@ -50,7 +50,7 @@ def retrieve_tool(
@router.get("/", response_model=List[Tool], operation_id="list_tools")
def list_tools(
cursor: Optional[str] = None,
after: Optional[str] = None,
limit: Optional[int] = 50,
server: SyncServer = Depends(get_letta_server),
user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
@ -60,7 +60,7 @@ def list_tools(
"""
try:
actor = server.user_manager.get_user_or_default(user_id=user_id)
return server.tool_manager.list_tools(actor=actor, cursor=cursor, limit=limit)
return server.tool_manager.list_tools(actor=actor, after=after, limit=limit)
except Exception as e:
# Log or print the full exception here for debugging
print(f"Error occurred: {e}")

View File

@ -15,7 +15,7 @@ router = APIRouter(prefix="/users", tags=["users", "admin"])
@router.get("/", tags=["admin"], response_model=List[User], operation_id="list_users")
def list_users(
cursor: Optional[str] = Query(None),
after: Optional[str] = Query(None),
limit: Optional[int] = Query(50),
server: "SyncServer" = Depends(get_letta_server),
):
@ -23,7 +23,7 @@ def list_users(
Get a list of all users in the database
"""
try:
next_cursor, users = server.user_manager.list_users(cursor=cursor, limit=limit)
users = server.user_manager.list_users(after=after, limit=limit)
except HTTPException:
raise
except Exception as e:

View File

@ -1,5 +1,6 @@
# inspecting tools
import asyncio
import json
import os
import traceback
import warnings
@ -38,7 +39,7 @@ from letta.schemas.letta_message import LegacyLettaMessage, LettaMessage, ToolRe
from letta.schemas.letta_response import LettaResponse
from letta.schemas.llm_config import LLMConfig
from letta.schemas.memory import ArchivalMemorySummary, ContextWindowOverview, Memory, RecallMemorySummary
from letta.schemas.message import Message, MessageCreate, MessageRole, MessageUpdate
from letta.schemas.message import Message, MessageCreate, MessageRole, MessageUpdate, TextContent
from letta.schemas.organization import Organization
from letta.schemas.passage import Passage
from letta.schemas.providers import (
@ -616,14 +617,14 @@ class SyncServer(Server):
message = Message(
agent_id=agent_id,
role="user",
text=packaged_user_message,
content=[TextContent(text=packaged_user_message)],
created_at=timestamp,
)
else:
message = Message(
agent_id=agent_id,
role="user",
text=packaged_user_message,
content=[TextContent(text=packaged_user_message)],
)
# Run the agent state forward
@ -666,14 +667,14 @@ class SyncServer(Server):
message = Message(
agent_id=agent_id,
role="system",
text=packaged_system_message,
content=[TextContent(text=packaged_system_message)],
created_at=timestamp,
)
else:
message = Message(
agent_id=agent_id,
role="system",
text=packaged_system_message,
content=[TextContent(text=packaged_system_message)],
)
if isinstance(message, Message):
@ -720,9 +721,9 @@ class SyncServer(Server):
# If wrapping is eanbled, wrap with metadata before placing content inside the Message object
if message.role == MessageRole.user and wrap_user_message:
message.text = system.package_user_message(user_message=message.text)
message.content = system.package_user_message(user_message=message.content)
elif message.role == MessageRole.system and wrap_system_message:
message.text = system.package_system_message(system_message=message.text)
message.content = system.package_system_message(system_message=message.content)
else:
raise ValueError(f"Invalid message role: {message.role}")
@ -731,7 +732,7 @@ class SyncServer(Server):
Message(
agent_id=agent_id,
role=message.role,
text=message.text,
content=[TextContent(text=message.content)],
name=message.name,
# assigned later?
model=None,
@ -804,20 +805,12 @@ class SyncServer(Server):
def get_recall_memory_summary(self, agent_id: str, actor: User) -> RecallMemorySummary:
return RecallMemorySummary(size=self.message_manager.size(actor=actor, agent_id=agent_id))
def get_agent_archival(self, user_id: str, agent_id: str, cursor: Optional[str] = None, limit: int = 50) -> List[Passage]:
"""Paginated query of all messages in agent archival memory"""
# TODO: Thread actor directly through this function, since the top level caller most likely already retrieved the user
actor = self.user_manager.get_user_or_default(user_id=user_id)
passages = self.agent_manager.list_passages(agent_id=agent_id, actor=actor)
return passages
def get_agent_archival_cursor(
def get_agent_archival(
self,
user_id: str,
agent_id: str,
cursor: Optional[str] = None,
after: Optional[str] = None,
before: Optional[str] = None,
limit: Optional[int] = 100,
order_by: Optional[str] = "created_at",
reverse: Optional[bool] = False,
@ -829,7 +822,8 @@ class SyncServer(Server):
records = self.agent_manager.list_passages(
actor=actor,
agent_id=agent_id,
cursor=cursor,
after=after,
before=before,
limit=limit,
ascending=not reverse,
)
@ -851,7 +845,7 @@ class SyncServer(Server):
# TODO: return archival memory
def get_agent_recall_cursor(
def get_agent_recall(
self,
user_id: str,
agent_id: str,
@ -1047,13 +1041,14 @@ class SyncServer(Server):
def list_llm_models(self) -> List[LLMConfig]:
"""List available models"""
llm_models = []
for provider in self.get_enabled_providers():
try:
llm_models.extend(provider.list_llm_models())
except Exception as e:
warnings.warn(f"An error occurred while listing LLM models for provider {provider}: {e}")
llm_models.extend(self.get_local_llm_configs())
return llm_models
def list_embedding_models(self) -> List[EmbeddingConfig]:
@ -1073,12 +1068,22 @@ class SyncServer(Server):
return {**providers_from_env, **providers_from_db}.values()
def get_llm_config_from_handle(self, handle: str, context_window_limit: Optional[int] = None) -> LLMConfig:
provider_name, model_name = handle.split("/", 1)
provider = self.get_provider_from_name(provider_name)
try:
provider_name, model_name = handle.split("/", 1)
provider = self.get_provider_from_name(provider_name)
llm_configs = [config for config in provider.list_llm_models() if config.model == model_name]
if not llm_configs:
raise ValueError(f"LLM model {model_name} is not supported by {provider_name}")
llm_configs = [config for config in provider.list_llm_models() if config.handle == handle]
if not llm_configs:
llm_configs = [config for config in provider.list_llm_models() if config.model == model_name]
if not llm_configs:
raise ValueError(f"LLM model {model_name} is not supported by {provider_name}")
except ValueError as e:
llm_configs = [config for config in self.get_local_llm_configs() if config.handle == handle]
if not llm_configs:
raise e
if len(llm_configs) == 1:
llm_config = llm_configs[0]
elif len(llm_configs) > 1:
raise ValueError(f"Multiple LLM models with name {model_name} supported by {provider_name}")
else:
@ -1097,13 +1102,17 @@ class SyncServer(Server):
provider_name, model_name = handle.split("/", 1)
provider = self.get_provider_from_name(provider_name)
embedding_configs = [config for config in provider.list_embedding_models() if config.embedding_model == model_name]
if not embedding_configs:
raise ValueError(f"Embedding model {model_name} is not supported by {provider_name}")
elif len(embedding_configs) > 1:
raise ValueError(f"Multiple embedding models with name {model_name} supported by {provider_name}")
else:
embedding_configs = [config for config in provider.list_embedding_models() if config.handle == handle]
if len(embedding_configs) == 1:
embedding_config = embedding_configs[0]
else:
embedding_configs = [config for config in provider.list_embedding_models() if config.embedding_model == model_name]
if not embedding_configs:
raise ValueError(f"Embedding model {model_name} is not supported by {provider_name}")
elif len(embedding_configs) > 1:
raise ValueError(f"Multiple embedding models with name {model_name} supported by {provider_name}")
else:
embedding_config = embedding_configs[0]
if embedding_chunk_size:
embedding_config.embedding_chunk_size = embedding_chunk_size
@ -1121,6 +1130,25 @@ class SyncServer(Server):
return provider
def get_local_llm_configs(self):
llm_models = []
try:
llm_configs_dir = os.path.expanduser("~/.letta/llm_configs")
if os.path.exists(llm_configs_dir):
for filename in os.listdir(llm_configs_dir):
if filename.endswith(".json"):
filepath = os.path.join(llm_configs_dir, filename)
try:
with open(filepath, "r") as f:
config_data = json.load(f)
llm_config = LLMConfig(**config_data)
llm_models.append(llm_config)
except (json.JSONDecodeError, ValueError) as e:
warnings.warn(f"Error parsing LLM config file {filename}: {e}")
except Exception as e:
warnings.warn(f"Error reading LLM configs directory: {e}")
return llm_models
def add_llm_model(self, request: LLMConfig) -> LLMConfig:
"""Add a new LLM model"""

View File

@ -2,7 +2,7 @@ from datetime import datetime
from typing import Dict, List, Optional
import numpy as np
from sqlalchemy import Select, func, literal, select, union_all
from sqlalchemy import Select, and_, func, literal, or_, select, union_all
from letta.constants import BASE_MEMORY_TOOLS, BASE_TOOLS, MAX_EMBEDDING_DIM, MULTI_AGENT_TOOLS
from letta.embeddings import embedding_model
@ -271,10 +271,11 @@ class AgentManager:
def list_agents(
self,
actor: PydanticUser,
before: Optional[str] = None,
after: Optional[str] = None,
limit: Optional[int] = 50,
tags: Optional[List[str]] = None,
match_all_tags: bool = False,
cursor: Optional[str] = None,
limit: Optional[int] = 50,
query_text: Optional[str] = None,
**kwargs,
) -> List[PydanticAgentState]:
@ -284,10 +285,11 @@ class AgentManager:
with self.session_maker() as session:
agents = AgentModel.list(
db_session=session,
before=before,
after=after,
limit=limit,
tags=tags,
match_all_tags=match_all_tags,
cursor=cursor,
limit=limit,
organization_id=actor.organization_id if actor else None,
query_text=query_text,
**kwargs,
@ -723,7 +725,8 @@ class AgentManager:
query_text: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
source_id: Optional[str] = None,
embed_query: bool = False,
ascending: bool = True,
@ -731,6 +734,7 @@ class AgentManager:
agent_only: bool = False,
) -> Select:
"""Helper function to build the base passage query with all filters applied.
Supports both before and after pagination across merged source and agent passages.
Returns the query before any limit or count operations are applied.
"""
@ -818,30 +822,69 @@ class AgentManager:
else:
# SQLite with custom vector type
query_embedding_binary = adapt_array(embedded_text)
if ascending:
main_query = main_query.order_by(
func.cosine_distance(combined_query.c.embedding, query_embedding_binary).asc(),
combined_query.c.created_at.asc(),
combined_query.c.id.asc(),
)
else:
main_query = main_query.order_by(
func.cosine_distance(combined_query.c.embedding, query_embedding_binary).asc(),
combined_query.c.created_at.desc(),
combined_query.c.id.asc(),
)
main_query = main_query.order_by(
func.cosine_distance(combined_query.c.embedding, query_embedding_binary).asc(),
combined_query.c.created_at.asc() if ascending else combined_query.c.created_at.desc(),
combined_query.c.id.asc(),
)
else:
if query_text:
main_query = main_query.where(func.lower(combined_query.c.text).contains(func.lower(query_text)))
# Handle cursor-based pagination
if cursor:
cursor_query = select(combined_query.c.created_at).where(combined_query.c.id == cursor).scalar_subquery()
# Handle pagination
if before or after:
# Create reference CTEs
if before:
before_ref = (
select(combined_query.c.created_at, combined_query.c.id).where(combined_query.c.id == before).cte("before_ref")
)
if after:
after_ref = (
select(combined_query.c.created_at, combined_query.c.id).where(combined_query.c.id == after).cte("after_ref")
)
if ascending:
main_query = main_query.where(combined_query.c.created_at > cursor_query)
if before and after:
# Window-based query (get records between before and after)
main_query = main_query.where(
or_(
combined_query.c.created_at < select(before_ref.c.created_at).scalar_subquery(),
and_(
combined_query.c.created_at == select(before_ref.c.created_at).scalar_subquery(),
combined_query.c.id < select(before_ref.c.id).scalar_subquery(),
),
)
)
main_query = main_query.where(
or_(
combined_query.c.created_at > select(after_ref.c.created_at).scalar_subquery(),
and_(
combined_query.c.created_at == select(after_ref.c.created_at).scalar_subquery(),
combined_query.c.id > select(after_ref.c.id).scalar_subquery(),
),
)
)
else:
main_query = main_query.where(combined_query.c.created_at < cursor_query)
# Pure pagination (only before or only after)
if before:
main_query = main_query.where(
or_(
combined_query.c.created_at < select(before_ref.c.created_at).scalar_subquery(),
and_(
combined_query.c.created_at == select(before_ref.c.created_at).scalar_subquery(),
combined_query.c.id < select(before_ref.c.id).scalar_subquery(),
),
)
)
if after:
main_query = main_query.where(
or_(
combined_query.c.created_at > select(after_ref.c.created_at).scalar_subquery(),
and_(
combined_query.c.created_at == select(after_ref.c.created_at).scalar_subquery(),
combined_query.c.id > select(after_ref.c.id).scalar_subquery(),
),
)
)
# Add ordering if not already ordered by similarity
if not embed_query:
@ -856,7 +899,7 @@ class AgentManager:
combined_query.c.id.asc(),
)
return main_query
return main_query
@enforce_types
def list_passages(
@ -868,7 +911,8 @@ class AgentManager:
query_text: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
source_id: Optional[str] = None,
embed_query: bool = False,
ascending: bool = True,
@ -884,7 +928,8 @@ class AgentManager:
query_text=query_text,
start_date=start_date,
end_date=end_date,
cursor=cursor,
before=before,
after=after,
source_id=source_id,
embed_query=embed_query,
ascending=ascending,
@ -924,7 +969,8 @@ class AgentManager:
query_text: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
source_id: Optional[str] = None,
embed_query: bool = False,
ascending: bool = True,
@ -940,7 +986,8 @@ class AgentManager:
query_text=query_text,
start_date=start_date,
end_date=end_date,
cursor=cursor,
before=before,
after=after,
source_id=source_id,
embed_query=embed_query,
ascending=ascending,
@ -1044,14 +1091,14 @@ class AgentManager:
# ======================================================================================================================
@enforce_types
def list_tags(
self, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50, query_text: Optional[str] = None
self, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50, query_text: Optional[str] = None
) -> List[str]:
"""
Get all tags a user has created, ordered alphabetically.
Args:
actor: User performing the action.
cursor: Cursor for pagination.
after: Cursor for forward pagination.
limit: Maximum number of tags to return.
query_text: Query text to filter tags by.
@ -1069,8 +1116,8 @@ class AgentManager:
if query_text:
query = query.filter(AgentsTags.tag.ilike(f"%{query_text}%"))
if cursor:
query = query.filter(AgentsTags.tag > cursor)
if after:
query = query.filter(AgentsTags.tag > after)
query = query.order_by(AgentsTags.tag).limit(limit)
results = [tag[0] for tag in query.all()]

View File

@ -3,6 +3,7 @@ from typing import List, Optional
from letta.orm.block import Block as BlockModel
from letta.orm.errors import NoResultFound
from letta.schemas.agent import AgentState as PydanticAgentState
from letta.schemas.block import Block
from letta.schemas.block import Block as PydanticBlock
from letta.schemas.block import BlockUpdate, Human, Persona
@ -64,7 +65,7 @@ class BlockManager:
is_template: Optional[bool] = None,
template_name: Optional[str] = None,
id: Optional[str] = None,
cursor: Optional[str] = None,
after: Optional[str] = None,
limit: Optional[int] = 50,
) -> List[PydanticBlock]:
"""Retrieve blocks based on various optional filters."""
@ -80,7 +81,7 @@ class BlockManager:
if id:
filters["id"] = id
blocks = BlockModel.list(db_session=session, cursor=cursor, limit=limit, **filters)
blocks = BlockModel.list(db_session=session, after=after, limit=limit, **filters)
return [block.to_pydantic() for block in blocks]
@ -114,3 +115,15 @@ class BlockManager:
text = open(human_file, "r", encoding="utf-8").read()
name = os.path.basename(human_file).replace(".txt", "")
self.create_or_update_block(Human(template_name=name, value=text, is_template=True), actor=actor)
@enforce_types
def get_agents_for_block(self, block_id: str, actor: PydanticUser) -> List[PydanticAgentState]:
"""
Retrieve all agents associated with a given block.
"""
with self.session_maker() as session:
block = BlockModel.read(db_session=session, identifier=block_id, actor=actor)
agents_orm = block.agents
agents_pydantic = [agent.to_pydantic() for agent in agents_orm]
return agents_pydantic

View File

@ -11,7 +11,7 @@ from letta.prompts import gpt_system
from letta.schemas.agent import AgentState, AgentType
from letta.schemas.enums import MessageRole
from letta.schemas.memory import Memory
from letta.schemas.message import Message, MessageCreate
from letta.schemas.message import Message, MessageCreate, TextContent
from letta.schemas.tool_rule import ToolRule
from letta.schemas.user import User
from letta.system import get_initial_boot_messages, get_login_event
@ -234,17 +234,24 @@ def package_initial_message_sequence(
if message_create.role == MessageRole.user:
packed_message = system.package_user_message(
user_message=message_create.text,
user_message=message_create.content,
)
elif message_create.role == MessageRole.system:
packed_message = system.package_system_message(
system_message=message_create.text,
system_message=message_create.content,
)
else:
raise ValueError(f"Invalid message role: {message_create.role}")
init_messages.append(
Message(role=message_create.role, text=packed_message, organization_id=actor.organization_id, agent_id=agent_id, model=model)
Message(
role=message_create.role,
content=[TextContent(text=packed_message)],
name=message_create.name,
organization_id=actor.organization_id,
agent_id=agent_id,
model=model,
)
)
return init_messages

View File

@ -78,10 +78,12 @@ class JobManager:
def list_jobs(
self,
actor: PydanticUser,
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
limit: Optional[int] = 50,
statuses: Optional[List[JobStatus]] = None,
job_type: JobType = JobType.JOB,
ascending: bool = True,
) -> List[PydanticJob]:
"""List all jobs with optional pagination and status filter."""
with self.session_maker() as session:
@ -93,8 +95,10 @@ class JobManager:
jobs = JobModel.list(
db_session=session,
cursor=cursor,
before=before,
after=after,
limit=limit,
ascending=ascending,
**filter_kwargs,
)
return [job.to_pydantic() for job in jobs]
@ -112,7 +116,8 @@ class JobManager:
self,
job_id: str,
actor: PydanticUser,
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
limit: Optional[int] = 100,
role: Optional[MessageRole] = None,
ascending: bool = True,
@ -123,7 +128,8 @@ class JobManager:
Args:
job_id: The ID of the job to get messages for
actor: The user making the request
cursor: Cursor for pagination
before: Cursor for pagination
after: Cursor for pagination
limit: Maximum number of messages to return
role: Optional filter for message role
ascending: Optional flag to sort in ascending order
@ -143,7 +149,8 @@ class JobManager:
# Get messages
messages = MessageModel.list(
db_session=session,
cursor=cursor,
before=before,
after=after,
ascending=ascending,
limit=limit,
actor=actor,
@ -255,11 +262,12 @@ class JobManager:
session.commit()
@enforce_types
def get_run_messages_cursor(
def get_run_messages(
self,
run_id: str,
actor: PydanticUser,
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
limit: Optional[int] = 100,
role: Optional[MessageRole] = None,
ascending: bool = True,
@ -271,7 +279,8 @@ class JobManager:
Args:
job_id: The ID of the job to get messages for
actor: The user making the request
cursor: Message ID to get messages after or before
before: Message ID to get messages after
after: Message ID to get messages before
limit: Maximum number of messages to return
ascending: Whether to return messages in ascending order
role: Optional role filter
@ -285,7 +294,8 @@ class JobManager:
messages = self.get_job_messages(
job_id=run_id,
actor=actor,
cursor=cursor,
before=before,
after=after,
limit=limit,
role=role,
ascending=ascending,

View File

@ -49,7 +49,7 @@ class MessageManager:
with self.session_maker() as session:
# Set the organization id of the Pydantic message
pydantic_msg.organization_id = actor.organization_id
msg_data = pydantic_msg.model_dump()
msg_data = pydantic_msg.model_dump(to_orm=True)
msg = MessageModel(**msg_data)
msg.create(session, actor=actor) # Persist to database
return msg.to_pydantic()
@ -83,7 +83,7 @@ class MessageManager:
)
# get update dictionary
update_data = message_update.model_dump(exclude_unset=True, exclude_none=True)
update_data = message_update.model_dump(to_orm=True, exclude_unset=True, exclude_none=True)
# Remove redundant update fields
update_data = {key: value for key, value in update_data.items() if getattr(message, key) != value}
@ -128,7 +128,8 @@ class MessageManager:
self,
agent_id: str,
actor: Optional[PydanticUser] = None,
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: Optional[int] = 50,
@ -139,7 +140,8 @@ class MessageManager:
"""List user messages with flexible filtering and pagination options.
Args:
cursor: Cursor-based pagination - return records after this ID (exclusive)
before: Cursor-based pagination - return records before this ID (exclusive)
after: Cursor-based pagination - return records after this ID (exclusive)
start_date: Filter records created after this date
end_date: Filter records created before this date
limit: Maximum number of records to return
@ -156,7 +158,8 @@ class MessageManager:
return self.list_messages_for_agent(
agent_id=agent_id,
actor=actor,
cursor=cursor,
before=before,
after=after,
start_date=start_date,
end_date=end_date,
limit=limit,
@ -170,7 +173,8 @@ class MessageManager:
self,
agent_id: str,
actor: Optional[PydanticUser] = None,
cursor: Optional[str] = None,
before: Optional[str] = None,
after: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: Optional[int] = 50,
@ -181,7 +185,8 @@ class MessageManager:
"""List messages with flexible filtering and pagination options.
Args:
cursor: Cursor-based pagination - return records after this ID (exclusive)
before: Cursor-based pagination - return records before this ID (exclusive)
after: Cursor-based pagination - return records after this ID (exclusive)
start_date: Filter records created after this date
end_date: Filter records created before this date
limit: Maximum number of records to return
@ -201,7 +206,8 @@ class MessageManager:
results = MessageModel.list(
db_session=session,
cursor=cursor,
before=before,
after=after,
start_date=start_date,
end_date=end_date,
limit=limit,

View File

@ -71,8 +71,12 @@ class OrganizationManager:
organization.hard_delete(session)
@enforce_types
def list_organizations(self, cursor: Optional[str] = None, limit: Optional[int] = 50) -> List[PydanticOrganization]:
"""List organizations with pagination based on cursor (org_id) and limit."""
def list_organizations(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[PydanticOrganization]:
"""List all organizations with optional pagination."""
with self.session_maker() as session:
results = OrganizationModel.list(db_session=session, cursor=cursor, limit=limit)
return [org.to_pydantic() for org in results]
organizations = OrganizationModel.list(
db_session=session,
after=after,
limit=limit,
)
return [org.to_pydantic() for org in organizations]

View File

@ -59,11 +59,15 @@ class ProviderManager:
session.commit()
@enforce_types
def list_providers(self, cursor: Optional[str] = None, limit: Optional[int] = 50) -> List[PydanticProvider]:
"""List providers with pagination using cursor (id) and limit."""
def list_providers(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[PydanticProvider]:
"""List all providers with optional pagination."""
with self.session_maker() as session:
results = ProviderModel.list(db_session=session, cursor=cursor, limit=limit)
return [provider.to_pydantic() for provider in results]
providers = ProviderModel.list(
db_session=session,
after=after,
limit=limit,
)
return [provider.to_pydantic() for provider in providers]
@enforce_types
def get_anthropic_override_provider_id(self) -> Optional[str]:

View File

@ -111,7 +111,11 @@ class SandboxConfigManager:
@enforce_types
def list_sandbox_configs(
self, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50, sandbox_type: Optional[SandboxType] = None
self,
actor: PydanticUser,
after: Optional[str] = None,
limit: Optional[int] = 50,
sandbox_type: Optional[SandboxType] = None,
) -> List[PydanticSandboxConfig]:
"""List all sandbox configurations with optional pagination."""
kwargs = {"organization_id": actor.organization_id}
@ -119,7 +123,7 @@ class SandboxConfigManager:
kwargs.update({"type": sandbox_type})
with self.session_maker() as session:
sandboxes = SandboxConfigModel.list(db_session=session, cursor=cursor, limit=limit, **kwargs)
sandboxes = SandboxConfigModel.list(db_session=session, after=after, limit=limit, **kwargs)
return [sandbox.to_pydantic() for sandbox in sandboxes]
@enforce_types
@ -207,13 +211,17 @@ class SandboxConfigManager:
@enforce_types
def list_sandbox_env_vars(
self, sandbox_config_id: str, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50
self,
sandbox_config_id: str,
actor: PydanticUser,
after: Optional[str] = None,
limit: Optional[int] = 50,
) -> List[PydanticEnvVar]:
"""List all sandbox environment variables with optional pagination."""
with self.session_maker() as session:
env_vars = SandboxEnvVarModel.list(
db_session=session,
cursor=cursor,
after=after,
limit=limit,
organization_id=actor.organization_id,
sandbox_config_id=sandbox_config_id,
@ -222,13 +230,13 @@ class SandboxConfigManager:
@enforce_types
def list_sandbox_env_vars_by_key(
self, key: str, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50
self, key: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50
) -> List[PydanticEnvVar]:
"""List all sandbox environment variables with optional pagination."""
with self.session_maker() as session:
env_vars = SandboxEnvVarModel.list(
db_session=session,
cursor=cursor,
after=after,
limit=limit,
organization_id=actor.organization_id,
key=key,
@ -237,9 +245,9 @@ class SandboxConfigManager:
@enforce_types
def get_sandbox_env_vars_as_dict(
self, sandbox_config_id: str, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50
self, sandbox_config_id: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50
) -> Dict[str, str]:
env_vars = self.list_sandbox_env_vars(sandbox_config_id, actor, cursor, limit)
env_vars = self.list_sandbox_env_vars(sandbox_config_id, actor, after, limit)
result = {}
for env_var in env_vars:
result[env_var.key] = env_var.value

View File

@ -65,12 +65,12 @@ class SourceManager:
return source.to_pydantic()
@enforce_types
def list_sources(self, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50, **kwargs) -> List[PydanticSource]:
def list_sources(self, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50, **kwargs) -> List[PydanticSource]:
"""List all sources with optional pagination."""
with self.session_maker() as session:
sources = SourceModel.list(
db_session=session,
cursor=cursor,
after=after,
limit=limit,
organization_id=actor.organization_id,
**kwargs,
@ -149,12 +149,12 @@ class SourceManager:
@enforce_types
def list_files(
self, source_id: str, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50
self, source_id: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50
) -> List[PydanticFileMetadata]:
"""List all files with optional pagination."""
with self.session_maker() as session:
files = FileMetadataModel.list(
db_session=session, cursor=cursor, limit=limit, organization_id=actor.organization_id, source_id=source_id
db_session=session, after=after, limit=limit, organization_id=actor.organization_id, source_id=source_id
)
return [file.to_pydantic() for file in files]

View File

@ -93,12 +93,12 @@ class ToolManager:
return None
@enforce_types
def list_tools(self, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50) -> List[PydanticTool]:
"""List all tools with optional pagination using cursor and limit."""
def list_tools(self, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50) -> List[PydanticTool]:
"""List all tools with optional pagination."""
with self.session_maker() as session:
tools = ToolModel.list(
db_session=session,
cursor=cursor,
after=after,
limit=limit,
organization_id=actor.organization_id,
)

View File

@ -1,4 +1,4 @@
from typing import List, Optional, Tuple
from typing import List, Optional
from letta.orm.errors import NoResultFound
from letta.orm.organization import Organization as OrganizationModel
@ -99,8 +99,12 @@ class UserManager:
return self.get_default_user()
@enforce_types
def list_users(self, cursor: Optional[str] = None, limit: Optional[int] = 50) -> Tuple[Optional[str], List[PydanticUser]]:
"""List users with pagination using cursor (id) and limit."""
def list_users(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[PydanticUser]:
"""List all users with optional pagination."""
with self.session_maker() as session:
results = UserModel.list(db_session=session, cursor=cursor, limit=limit)
return [user.to_pydantic() for user in results]
users = UserModel.list(
db_session=session,
after=after,
limit=limit,
)
return [user.to_pydantic() for user in users]

View File

@ -55,7 +55,7 @@ class LettaUser(HttpUser):
@task(1)
def send_message(self):
messages = [MessageCreate(role=MessageRole("user"), text="hello")]
messages = [MessageCreate(role=MessageRole("user"), content="hello")]
request = LettaRequest(messages=messages)
with self.client.post(
@ -70,7 +70,7 @@ class LettaUser(HttpUser):
# @task(1)
# def send_message_stream(self):
# messages = [MessageCreate(role=MessageRole("user"), text="hello")]
# messages = [MessageCreate(role=MessageRole("user"), content="hello")]
# request = LettaRequest(messages=messages, stream_steps=True, stream_tokens=True, return_message_object=True)
# if stream_tokens or stream_steps:
# from letta.client.streaming import _sse_post

264
poetry.lock generated
View File

@ -1671,137 +1671,137 @@ test = ["objgraph", "psutil"]
[[package]]
name = "grpcio"
version = "1.69.0"
version = "1.70.0"
description = "HTTP/2-based RPC framework"
optional = false
python-versions = ">=3.8"
files = [
{file = "grpcio-1.69.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:2060ca95a8db295ae828d0fc1c7f38fb26ccd5edf9aa51a0f44251f5da332e97"},
{file = "grpcio-1.69.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:2e52e107261fd8fa8fa457fe44bfadb904ae869d87c1280bf60f93ecd3e79278"},
{file = "grpcio-1.69.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:316463c0832d5fcdb5e35ff2826d9aa3f26758d29cdfb59a368c1d6c39615a11"},
{file = "grpcio-1.69.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26c9a9c4ac917efab4704b18eed9082ed3b6ad19595f047e8173b5182fec0d5e"},
{file = "grpcio-1.69.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90b3646ced2eae3a0599658eeccc5ba7f303bf51b82514c50715bdd2b109e5ec"},
{file = "grpcio-1.69.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3b75aea7c6cb91b341c85e7c1d9db1e09e1dd630b0717f836be94971e015031e"},
{file = "grpcio-1.69.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5cfd14175f9db33d4b74d63de87c64bb0ee29ce475ce3c00c01ad2a3dc2a9e51"},
{file = "grpcio-1.69.0-cp310-cp310-win32.whl", hash = "sha256:9031069d36cb949205293cf0e243abd5e64d6c93e01b078c37921493a41b72dc"},
{file = "grpcio-1.69.0-cp310-cp310-win_amd64.whl", hash = "sha256:cc89b6c29f3dccbe12d7a3b3f1b3999db4882ae076c1c1f6df231d55dbd767a5"},
{file = "grpcio-1.69.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8de1b192c29b8ce45ee26a700044717bcbbd21c697fa1124d440548964328561"},
{file = "grpcio-1.69.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:7e76accf38808f5c5c752b0ab3fd919eb14ff8fafb8db520ad1cc12afff74de6"},
{file = "grpcio-1.69.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:d5658c3c2660417d82db51e168b277e0ff036d0b0f859fa7576c0ffd2aec1442"},
{file = "grpcio-1.69.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5494d0e52bf77a2f7eb17c6da662886ca0a731e56c1c85b93505bece8dc6cf4c"},
{file = "grpcio-1.69.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ed866f9edb574fd9be71bf64c954ce1b88fc93b2a4cbf94af221e9426eb14d6"},
{file = "grpcio-1.69.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c5ba38aeac7a2fe353615c6b4213d1fbb3a3c34f86b4aaa8be08baaaee8cc56d"},
{file = "grpcio-1.69.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f79e05f5bbf551c4057c227d1b041ace0e78462ac8128e2ad39ec58a382536d2"},
{file = "grpcio-1.69.0-cp311-cp311-win32.whl", hash = "sha256:bf1f8be0da3fcdb2c1e9f374f3c2d043d606d69f425cd685110dd6d0d2d61258"},
{file = "grpcio-1.69.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb9302afc3a0e4ba0b225cd651ef8e478bf0070cf11a529175caecd5ea2474e7"},
{file = "grpcio-1.69.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:fc18a4de8c33491ad6f70022af5c460b39611e39578a4d84de0fe92f12d5d47b"},
{file = "grpcio-1.69.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:0f0270bd9ffbff6961fe1da487bdcd594407ad390cc7960e738725d4807b18c4"},
{file = "grpcio-1.69.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:dc48f99cc05e0698e689b51a05933253c69a8c8559a47f605cff83801b03af0e"},
{file = "grpcio-1.69.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e925954b18d41aeb5ae250262116d0970893b38232689c4240024e4333ac084"},
{file = "grpcio-1.69.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87d222569273720366f68a99cb62e6194681eb763ee1d3b1005840678d4884f9"},
{file = "grpcio-1.69.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b62b0f41e6e01a3e5082000b612064c87c93a49b05f7602fe1b7aa9fd5171a1d"},
{file = "grpcio-1.69.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:db6f9fd2578dbe37db4b2994c94a1d9c93552ed77dca80e1657bb8a05b898b55"},
{file = "grpcio-1.69.0-cp312-cp312-win32.whl", hash = "sha256:b192b81076073ed46f4b4dd612b8897d9a1e39d4eabd822e5da7b38497ed77e1"},
{file = "grpcio-1.69.0-cp312-cp312-win_amd64.whl", hash = "sha256:1227ff7836f7b3a4ab04e5754f1d001fa52a730685d3dc894ed8bc262cc96c01"},
{file = "grpcio-1.69.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:a78a06911d4081a24a1761d16215a08e9b6d4d29cdbb7e427e6c7e17b06bcc5d"},
{file = "grpcio-1.69.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:dc5a351927d605b2721cbb46158e431dd49ce66ffbacb03e709dc07a491dde35"},
{file = "grpcio-1.69.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:3629d8a8185f5139869a6a17865d03113a260e311e78fbe313f1a71603617589"},
{file = "grpcio-1.69.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9a281878feeb9ae26db0622a19add03922a028d4db684658f16d546601a4870"},
{file = "grpcio-1.69.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cc614e895177ab7e4b70f154d1a7c97e152577ea101d76026d132b7aaba003b"},
{file = "grpcio-1.69.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:1ee76cd7e2e49cf9264f6812d8c9ac1b85dda0eaea063af07292400f9191750e"},
{file = "grpcio-1.69.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:0470fa911c503af59ec8bc4c82b371ee4303ececbbdc055f55ce48e38b20fd67"},
{file = "grpcio-1.69.0-cp313-cp313-win32.whl", hash = "sha256:b650f34aceac8b2d08a4c8d7dc3e8a593f4d9e26d86751ebf74ebf5107d927de"},
{file = "grpcio-1.69.0-cp313-cp313-win_amd64.whl", hash = "sha256:028337786f11fecb5d7b7fa660475a06aabf7e5e52b5ac2df47414878c0ce7ea"},
{file = "grpcio-1.69.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:b7f693db593d6bf285e015d5538bf1c86cf9c60ed30b6f7da04a00ed052fe2f3"},
{file = "grpcio-1.69.0-cp38-cp38-macosx_10_14_universal2.whl", hash = "sha256:8b94e83f66dbf6fd642415faca0608590bc5e8d30e2c012b31d7d1b91b1de2fd"},
{file = "grpcio-1.69.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:b634851b92c090763dde61df0868c730376cdb73a91bcc821af56ae043b09596"},
{file = "grpcio-1.69.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf5f680d3ed08c15330d7830d06bc65f58ca40c9999309517fd62880d70cb06e"},
{file = "grpcio-1.69.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:200e48a6e7b00f804cf00a1c26292a5baa96507c7749e70a3ec10ca1a288936e"},
{file = "grpcio-1.69.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:45a4704339b6e5b24b0e136dea9ad3815a94f30eb4f1e1d44c4ac484ef11d8dd"},
{file = "grpcio-1.69.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:85d347cb8237751b23539981dbd2d9d8f6e9ff90082b427b13022b948eb6347a"},
{file = "grpcio-1.69.0-cp38-cp38-win32.whl", hash = "sha256:60e5de105dc02832dc8f120056306d0ef80932bcf1c0e2b4ca3b676de6dc6505"},
{file = "grpcio-1.69.0-cp38-cp38-win_amd64.whl", hash = "sha256:282f47d0928e40f25d007f24eb8fa051cb22551e3c74b8248bc9f9bea9c35fe0"},
{file = "grpcio-1.69.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:dd034d68a2905464c49479b0c209c773737a4245d616234c79c975c7c90eca03"},
{file = "grpcio-1.69.0-cp39-cp39-macosx_10_14_universal2.whl", hash = "sha256:01f834732c22a130bdf3dc154d1053bdbc887eb3ccb7f3e6285cfbfc33d9d5cc"},
{file = "grpcio-1.69.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:a7f4ed0dcf202a70fe661329f8874bc3775c14bb3911d020d07c82c766ce0eb1"},
{file = "grpcio-1.69.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd7ea241b10bc5f0bb0f82c0d7896822b7ed122b3ab35c9851b440c1ccf81588"},
{file = "grpcio-1.69.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f03dc9b4da4c0dc8a1db7a5420f575251d7319b7a839004d8916257ddbe4816"},
{file = "grpcio-1.69.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ca71d73a270dff052fe4edf74fef142d6ddd1f84175d9ac4a14b7280572ac519"},
{file = "grpcio-1.69.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ccbed100dc43704e94ccff9e07680b540d64e4cc89213ab2832b51b4f68a520"},
{file = "grpcio-1.69.0-cp39-cp39-win32.whl", hash = "sha256:1514341def9c6ec4b7f0b9628be95f620f9d4b99331b7ef0a1845fd33d9b579c"},
{file = "grpcio-1.69.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1fea55d26d647346acb0069b08dca70984101f2dc95066e003019207212e303"},
{file = "grpcio-1.69.0.tar.gz", hash = "sha256:936fa44241b5379c5afc344e1260d467bee495747eaf478de825bab2791da6f5"},
{file = "grpcio-1.70.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:95469d1977429f45fe7df441f586521361e235982a0b39e33841549143ae2851"},
{file = "grpcio-1.70.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:ed9718f17fbdb472e33b869c77a16d0b55e166b100ec57b016dc7de9c8d236bf"},
{file = "grpcio-1.70.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:374d014f29f9dfdb40510b041792e0e2828a1389281eb590df066e1cc2b404e5"},
{file = "grpcio-1.70.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2af68a6f5c8f78d56c145161544ad0febbd7479524a59c16b3e25053f39c87f"},
{file = "grpcio-1.70.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce7df14b2dcd1102a2ec32f621cc9fab6695effef516efbc6b063ad749867295"},
{file = "grpcio-1.70.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c78b339869f4dbf89881e0b6fbf376313e4f845a42840a7bdf42ee6caed4b11f"},
{file = "grpcio-1.70.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58ad9ba575b39edef71f4798fdb5c7b6d02ad36d47949cd381d4392a5c9cbcd3"},
{file = "grpcio-1.70.0-cp310-cp310-win32.whl", hash = "sha256:2b0d02e4b25a5c1f9b6c7745d4fa06efc9fd6a611af0fb38d3ba956786b95199"},
{file = "grpcio-1.70.0-cp310-cp310-win_amd64.whl", hash = "sha256:0de706c0a5bb9d841e353f6343a9defc9fc35ec61d6eb6111802f3aa9fef29e1"},
{file = "grpcio-1.70.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:17325b0be0c068f35770f944124e8839ea3185d6d54862800fc28cc2ffad205a"},
{file = "grpcio-1.70.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:dbe41ad140df911e796d4463168e33ef80a24f5d21ef4d1e310553fcd2c4a386"},
{file = "grpcio-1.70.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:5ea67c72101d687d44d9c56068328da39c9ccba634cabb336075fae2eab0d04b"},
{file = "grpcio-1.70.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb5277db254ab7586769e490b7b22f4ddab3876c490da0a1a9d7c695ccf0bf77"},
{file = "grpcio-1.70.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7831a0fc1beeeb7759f737f5acd9fdcda520e955049512d68fda03d91186eea"},
{file = "grpcio-1.70.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:27cc75e22c5dba1fbaf5a66c778e36ca9b8ce850bf58a9db887754593080d839"},
{file = "grpcio-1.70.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d63764963412e22f0491d0d32833d71087288f4e24cbcddbae82476bfa1d81fd"},
{file = "grpcio-1.70.0-cp311-cp311-win32.whl", hash = "sha256:bb491125103c800ec209d84c9b51f1c60ea456038e4734688004f377cfacc113"},
{file = "grpcio-1.70.0-cp311-cp311-win_amd64.whl", hash = "sha256:d24035d49e026353eb042bf7b058fb831db3e06d52bee75c5f2f3ab453e71aca"},
{file = "grpcio-1.70.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:ef4c14508299b1406c32bdbb9fb7b47612ab979b04cf2b27686ea31882387cff"},
{file = "grpcio-1.70.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:aa47688a65643afd8b166928a1da6247d3f46a2784d301e48ca1cc394d2ffb40"},
{file = "grpcio-1.70.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:880bfb43b1bb8905701b926274eafce5c70a105bc6b99e25f62e98ad59cb278e"},
{file = "grpcio-1.70.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e654c4b17d07eab259d392e12b149c3a134ec52b11ecdc6a515b39aceeec898"},
{file = "grpcio-1.70.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2394e3381071045a706ee2eeb6e08962dd87e8999b90ac15c55f56fa5a8c9597"},
{file = "grpcio-1.70.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b3c76701428d2df01964bc6479422f20e62fcbc0a37d82ebd58050b86926ef8c"},
{file = "grpcio-1.70.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ac073fe1c4cd856ebcf49e9ed6240f4f84d7a4e6ee95baa5d66ea05d3dd0df7f"},
{file = "grpcio-1.70.0-cp312-cp312-win32.whl", hash = "sha256:cd24d2d9d380fbbee7a5ac86afe9787813f285e684b0271599f95a51bce33528"},
{file = "grpcio-1.70.0-cp312-cp312-win_amd64.whl", hash = "sha256:0495c86a55a04a874c7627fd33e5beaee771917d92c0e6d9d797628ac40e7655"},
{file = "grpcio-1.70.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa573896aeb7d7ce10b1fa425ba263e8dddd83d71530d1322fd3a16f31257b4a"},
{file = "grpcio-1.70.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:d405b005018fd516c9ac529f4b4122342f60ec1cee181788249372524e6db429"},
{file = "grpcio-1.70.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f32090238b720eb585248654db8e3afc87b48d26ac423c8dde8334a232ff53c9"},
{file = "grpcio-1.70.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfa089a734f24ee5f6880c83d043e4f46bf812fcea5181dcb3a572db1e79e01c"},
{file = "grpcio-1.70.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f19375f0300b96c0117aca118d400e76fede6db6e91f3c34b7b035822e06c35f"},
{file = "grpcio-1.70.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:7c73c42102e4a5ec76608d9b60227d917cea46dff4d11d372f64cbeb56d259d0"},
{file = "grpcio-1.70.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:0a5c78d5198a1f0aa60006cd6eb1c912b4a1520b6a3968e677dbcba215fabb40"},
{file = "grpcio-1.70.0-cp313-cp313-win32.whl", hash = "sha256:fe9dbd916df3b60e865258a8c72ac98f3ac9e2a9542dcb72b7a34d236242a5ce"},
{file = "grpcio-1.70.0-cp313-cp313-win_amd64.whl", hash = "sha256:4119fed8abb7ff6c32e3d2255301e59c316c22d31ab812b3fbcbaf3d0d87cc68"},
{file = "grpcio-1.70.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:8058667a755f97407fca257c844018b80004ae8035565ebc2812cc550110718d"},
{file = "grpcio-1.70.0-cp38-cp38-macosx_10_14_universal2.whl", hash = "sha256:879a61bf52ff8ccacbedf534665bb5478ec8e86ad483e76fe4f729aaef867cab"},
{file = "grpcio-1.70.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:0ba0a173f4feacf90ee618fbc1a27956bfd21260cd31ced9bc707ef551ff7dc7"},
{file = "grpcio-1.70.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:558c386ecb0148f4f99b1a65160f9d4b790ed3163e8610d11db47838d452512d"},
{file = "grpcio-1.70.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:412faabcc787bbc826f51be261ae5fa996b21263de5368a55dc2cf824dc5090e"},
{file = "grpcio-1.70.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3b0f01f6ed9994d7a0b27eeddea43ceac1b7e6f3f9d86aeec0f0064b8cf50fdb"},
{file = "grpcio-1.70.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7385b1cb064734005204bc8994eed7dcb801ed6c2eda283f613ad8c6c75cf873"},
{file = "grpcio-1.70.0-cp38-cp38-win32.whl", hash = "sha256:07269ff4940f6fb6710951116a04cd70284da86d0a4368fd5a3b552744511f5a"},
{file = "grpcio-1.70.0-cp38-cp38-win_amd64.whl", hash = "sha256:aba19419aef9b254e15011b230a180e26e0f6864c90406fdbc255f01d83bc83c"},
{file = "grpcio-1.70.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:4f1937f47c77392ccd555728f564a49128b6a197a05a5cd527b796d36f3387d0"},
{file = "grpcio-1.70.0-cp39-cp39-macosx_10_14_universal2.whl", hash = "sha256:0cd430b9215a15c10b0e7d78f51e8a39d6cf2ea819fd635a7214fae600b1da27"},
{file = "grpcio-1.70.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:e27585831aa6b57b9250abaf147003e126cd3a6c6ca0c531a01996f31709bed1"},
{file = "grpcio-1.70.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1af8e15b0f0fe0eac75195992a63df17579553b0c4af9f8362cc7cc99ccddf4"},
{file = "grpcio-1.70.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbce24409beaee911c574a3d75d12ffb8c3e3dd1b813321b1d7a96bbcac46bf4"},
{file = "grpcio-1.70.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ff4a8112a79464919bb21c18e956c54add43ec9a4850e3949da54f61c241a4a6"},
{file = "grpcio-1.70.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5413549fdf0b14046c545e19cfc4eb1e37e9e1ebba0ca390a8d4e9963cab44d2"},
{file = "grpcio-1.70.0-cp39-cp39-win32.whl", hash = "sha256:b745d2c41b27650095e81dea7091668c040457483c9bdb5d0d9de8f8eb25e59f"},
{file = "grpcio-1.70.0-cp39-cp39-win_amd64.whl", hash = "sha256:a31d7e3b529c94e930a117b2175b2efd179d96eb3c7a21ccb0289a8ab05b645c"},
{file = "grpcio-1.70.0.tar.gz", hash = "sha256:8d1584a68d5922330025881e63a6c1b54cc8117291d382e4fa69339b6d914c56"},
]
[package.extras]
protobuf = ["grpcio-tools (>=1.69.0)"]
protobuf = ["grpcio-tools (>=1.70.0)"]
[[package]]
name = "grpcio-tools"
version = "1.69.0"
version = "1.70.0"
description = "Protobuf code generator for gRPC"
optional = false
python-versions = ">=3.8"
files = [
{file = "grpcio_tools-1.69.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:8c210630faa581c3bd08953dac4ad21a7f49862f3b92d69686e9b436d2f1265d"},
{file = "grpcio_tools-1.69.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:09b66ea279fcdaebae4ec34b1baf7577af3b14322738aa980c1c33cfea71f7d7"},
{file = "grpcio_tools-1.69.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:be94a4bfa56d356aae242cc54072c9ccc2704b659eaae2fd599a94afebf791ce"},
{file = "grpcio_tools-1.69.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28778debad73a8c8e0a0e07e6a2f76eecce43adbc205d17dd244d2d58bb0f0aa"},
{file = "grpcio_tools-1.69.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:449308d93e4c97ae3a4503510c6d64978748ff5e21429c85da14fdc783c0f498"},
{file = "grpcio_tools-1.69.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b9343651e73bc6e0df6bb518c2638bf9cc2194b50d060cdbcf1b2121cd4e4ae3"},
{file = "grpcio_tools-1.69.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2f08b063612553e726e328aef3a27adfaea8d92712b229012afc54d59da88a02"},
{file = "grpcio_tools-1.69.0-cp310-cp310-win32.whl", hash = "sha256:599ffd39525e7bbb6412a63e56a2e6c1af8f3493fe4305260efd4a11d064cce0"},
{file = "grpcio_tools-1.69.0-cp310-cp310-win_amd64.whl", hash = "sha256:02f92e3c2bae67ece818787f8d3d89df0fa1e5e6bbb7c1493824fd5dfad886dd"},
{file = "grpcio_tools-1.69.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:c18df5d1c8e163a29863583ec51237d08d7059ef8d4f7661ee6d6363d3e38fe3"},
{file = "grpcio_tools-1.69.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:37876ae49235ef2e61e5059faf45dc5e7142ca54ae61aec378bb9483e0cd7e95"},
{file = "grpcio_tools-1.69.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:33120920e29959eaa37a1268c6a22af243d086b1a5e5222b4203e29560ece9ce"},
{file = "grpcio_tools-1.69.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:788bb3ecd1b44664d829d319b3c1ebc15c7d7b5e7d1f22706ab57d6acd2c6301"},
{file = "grpcio_tools-1.69.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f453b11a112e3774c8957ec2570669f3da1f7fbc8ee242482c38981496e88da2"},
{file = "grpcio_tools-1.69.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7e5c5dc2b656755cb58b11a7e87b65258a4a8eaff01b6c30ffcb230dd447c03d"},
{file = "grpcio_tools-1.69.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8eabf0a7a98c14322bc74f9910c96f98feebe311e085624b2d022924d4f652ca"},
{file = "grpcio_tools-1.69.0-cp311-cp311-win32.whl", hash = "sha256:ad567bea43d018c2215e1db10316eda94ca19229a834a3221c15d132d24c1b8a"},
{file = "grpcio_tools-1.69.0-cp311-cp311-win_amd64.whl", hash = "sha256:3d64e801586dbea3530f245d48b9ed031738cc3eb099d5ce2fdb1b3dc2e1fb20"},
{file = "grpcio_tools-1.69.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8ef8efe8beac4cc1e30d41893e4096ca2601da61001897bd17441645de2d4d3c"},
{file = "grpcio_tools-1.69.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:a00e87a0c5a294028115a098819899b08dd18449df5b2aac4a2b87ba865e8681"},
{file = "grpcio_tools-1.69.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:7722700346d5b223159532e046e51f2ff743ed4342e5fe3e0457120a4199015e"},
{file = "grpcio_tools-1.69.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a934116fdf202cb675246056ee54645c743e2240632f86a37e52f91a405c7143"},
{file = "grpcio_tools-1.69.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e6a6d44359ca836acfbc58103daf94b3bb8ac919d659bb348dcd7fbecedc293"},
{file = "grpcio_tools-1.69.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e27662c0597fd1ab5399a583d358b5203edcb6fc2b29d6245099dfacd51a6ddc"},
{file = "grpcio_tools-1.69.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7bbb2b2fb81d95bcdd1d8331defb5f5dc256dbe423bb98b682cf129cdd432366"},
{file = "grpcio_tools-1.69.0-cp312-cp312-win32.whl", hash = "sha256:e11accd10cf4af5031ac86c45f1a13fb08f55e005cea070917c12e78fe6d2aa2"},
{file = "grpcio_tools-1.69.0-cp312-cp312-win_amd64.whl", hash = "sha256:6df4c6ac109af338a8ccde29d184e0b0bdab13d78490cb360ff9b192a1aec7e2"},
{file = "grpcio_tools-1.69.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:8c320c4faa1431f2e1252ef2325a970ac23b2fd04ffef6c12f96dd4552c3445c"},
{file = "grpcio_tools-1.69.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:5f1224596ad74dd14444b20c37122b361c5d203b67e14e018b995f3c5d76eede"},
{file = "grpcio_tools-1.69.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:965a0cf656a113bc32d15ac92ca51ed702a75d5370ae0afbdd36f818533a708a"},
{file = "grpcio_tools-1.69.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:978835768c11a7f28778b3b7c40f839d8a57f765c315e80c4246c23900d56149"},
{file = "grpcio_tools-1.69.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:094c7cec9bd271a32dfb7c620d4a558c63fcb0122fd1651b9ed73d6afd4ae6fe"},
{file = "grpcio_tools-1.69.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:b51bf4981b3d7e47c2569efadff08284787124eb3dea0f63f491d39703231d3c"},
{file = "grpcio_tools-1.69.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ea7aaf0dc1a828e2133357a9e9553fd1bb4e766890d52a506cc132e40632acdc"},
{file = "grpcio_tools-1.69.0-cp313-cp313-win32.whl", hash = "sha256:4320f11b79d3a148cc23bad1b81719ce1197808dc2406caa8a8ba0a5cfb0260d"},
{file = "grpcio_tools-1.69.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9bae733654e0eb8ca83aa1d0d6b6c2f4a3525ce70d5ffc07df68d28f6520137"},
{file = "grpcio_tools-1.69.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:c78d3a7d9ba4292ba7abcc43430df426fc805e79a1dcd147509af0668332885b"},
{file = "grpcio_tools-1.69.0-cp38-cp38-macosx_10_14_universal2.whl", hash = "sha256:497bdaa996a4de70f643c008a08813b4d20e114de50a384ae5e29d849c24c9c8"},
{file = "grpcio_tools-1.69.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:aea33dd5a07a3b250b02a1b3f435e86d4abc94936b3ce634a2d70bc224189495"},
{file = "grpcio_tools-1.69.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d3101c8d6f890f9d978e400843cc29992c5e03ae74f359e73dade09f2469a08"},
{file = "grpcio_tools-1.69.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1163ba3f829141206dce1ceb67cfca73b57d279cd7183f188276443700a4980e"},
{file = "grpcio_tools-1.69.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a85785058c31bac3d0b26c158b576eed536e4ce1af72c1d05a3518e745d44aac"},
{file = "grpcio_tools-1.69.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4ee934bbe8aa8035eea2711c12a6e537ab4c4a35a6d742ccf34bfa3a0492f412"},
{file = "grpcio_tools-1.69.0-cp38-cp38-win32.whl", hash = "sha256:808d1b963bda8ca3c9f55cb8aa051ed2f2c98cc1fb89f79b4f67e8218580f8f3"},
{file = "grpcio_tools-1.69.0-cp38-cp38-win_amd64.whl", hash = "sha256:afa8cd6b93e4f607c3750a976a96f874830ec7dc5f408e0fac270d0464147024"},
{file = "grpcio_tools-1.69.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:01121b6570932bfb7d8b2ce2c0055dba902a415477079e249d85fe4494f72db2"},
{file = "grpcio_tools-1.69.0-cp39-cp39-macosx_10_14_universal2.whl", hash = "sha256:9861e282aa7b3656c67e84d0c25ee0e9210b955e0ec2c64699b8f80483f90853"},
{file = "grpcio_tools-1.69.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:00adf628259e8c314a02ca1580d6a8b14eeef266f5dd5e15bf92c1efbbcf63c0"},
{file = "grpcio_tools-1.69.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:371d03ac31b76ba77d44bdba6a8560f344c6d1ed558babab64760da085e392b7"},
{file = "grpcio_tools-1.69.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6730414c01fe9027ba12538fd6e192e1bea94d5b819a1e03d15e89aab1b4573"},
{file = "grpcio_tools-1.69.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5562a1b1b67deffd04fbb1bcf8f1634580538ce35895b77cdfaec1fb115efd95"},
{file = "grpcio_tools-1.69.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f8996efddc867134f22bbf8a368b1b2a018d0a9b0ac9d3185cfd81d1abd8066"},
{file = "grpcio_tools-1.69.0-cp39-cp39-win32.whl", hash = "sha256:8f5959d8a453d613e7137831f6885b43b5c378ec317943b4ec599046baa97bfc"},
{file = "grpcio_tools-1.69.0-cp39-cp39-win_amd64.whl", hash = "sha256:5d47abf7e0662dd5dbb9cc252c3616e5fbc5f71d34e3f6332cd24bcdf2940abd"},
{file = "grpcio_tools-1.69.0.tar.gz", hash = "sha256:3e1a98f4d9decb84979e1ddd3deb09c0a33a84b6e3c0776d5bde4097e3ab66dd"},
{file = "grpcio_tools-1.70.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:4d456521290e25b1091975af71604facc5c7db162abdca67e12a0207b8bbacbe"},
{file = "grpcio_tools-1.70.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:d50080bca84f53f3a05452e06e6251cbb4887f5a1d1321d1989e26d6e0dc398d"},
{file = "grpcio_tools-1.70.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:02e3bf55fb569fe21b54a32925979156e320f9249bb247094c4cbaa60c23a80d"},
{file = "grpcio_tools-1.70.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88a3ec6fa2381f616d567f996503e12ca353777941b61030fd9733fd5772860e"},
{file = "grpcio_tools-1.70.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6034a0579fab2aed8685fa1a558de084668b1e9b01a82a4ca7458b9bedf4654c"},
{file = "grpcio_tools-1.70.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:701bbb1ff406a21a771f5b1df6be516c0a59236774b6836eaad7696b1d128ea8"},
{file = "grpcio_tools-1.70.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6eeb86864e1432fc1ab61e03395a2a4c04e9dd9c89db07e6fe68c7c2ac8ec24f"},
{file = "grpcio_tools-1.70.0-cp310-cp310-win32.whl", hash = "sha256:d53c8c45e843b5836781ad6b82a607c72c2f9a3f556e23d703a0e099222421fa"},
{file = "grpcio_tools-1.70.0-cp310-cp310-win_amd64.whl", hash = "sha256:22024caee36ab65c2489594d718921dcbb5bd18d61c5417a9ede94fd8dc8a589"},
{file = "grpcio_tools-1.70.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:5f5aba12d98d25c7ab2dd983939e2c21556a7d15f903b286f24d88d2c6e30c0a"},
{file = "grpcio_tools-1.70.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:d47a6c6cfc526b290b7b53a37dd7e6932983f7a168b56aab760b4b597c47f30f"},
{file = "grpcio_tools-1.70.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:b5a9beadd1e24772ffa2c70f07d72f73330d356b78b246e424f4f2ed6c6713f3"},
{file = "grpcio_tools-1.70.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb8135eef160a62505f074bf7a3d62f3b13911c3c14037c5392bf877114213b5"},
{file = "grpcio_tools-1.70.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ac9b3e13ace8467a586c53580ee22f9732c355583f3c344ef8c6c0666219cc"},
{file = "grpcio_tools-1.70.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:63f367363a4a1489a0046b19f9d561216ea0d206c40a6f1bf07a58ccfb7be480"},
{file = "grpcio_tools-1.70.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54ceffef59a059d2c7304554a8bbb20eedb05a3f937159ab1c332c1b28e12c9f"},
{file = "grpcio_tools-1.70.0-cp311-cp311-win32.whl", hash = "sha256:7a90a66a46821140a2a2b0be787dfabe42e22e9a5ba9cc70726b3e5c71a3b785"},
{file = "grpcio_tools-1.70.0-cp311-cp311-win_amd64.whl", hash = "sha256:4ebf09733545a69c166b02caa14c34451e38855544820dab7fdde5c28e2dbffe"},
{file = "grpcio_tools-1.70.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:ec5d6932c3173d7618267b3b3fd77b9243949c5ec04302b7338386d4f8544e0b"},
{file = "grpcio_tools-1.70.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:f22852da12f53b02a3bdb29d0c32fcabab9c7c8f901389acffec8461083f110d"},
{file = "grpcio_tools-1.70.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:7d45067e6efd20881e98a0e1d7edd7f207b1625ad7113321becbfe0a6ebee46c"},
{file = "grpcio_tools-1.70.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3020c97f03b30eee3c26aa2a55fbe003f1729c6f879a378507c2c78524db7c12"},
{file = "grpcio_tools-1.70.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7fd472fce3b33bdf7fbc24d40da7ab10d7a088bcaf59c37433c2c57330fbcb6"},
{file = "grpcio_tools-1.70.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3875543d74ce1a698a11f498f83795216ce929cb29afa5fac15672c7ba1d6dd2"},
{file = "grpcio_tools-1.70.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a130c24d617a3a57369da784080dfa8848444d41b7ae1250abc06e72e706a8d9"},
{file = "grpcio_tools-1.70.0-cp312-cp312-win32.whl", hash = "sha256:8eae17c920d14e2e451dbb18f5d8148f884e10228061941b33faa8fceee86e73"},
{file = "grpcio_tools-1.70.0-cp312-cp312-win_amd64.whl", hash = "sha256:99caa530242a0a832d8b6a6ab94b190c9b449d3e237f953911b4d56207569436"},
{file = "grpcio_tools-1.70.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:f024688d04e7a9429489ed695b85628075c3c6d655198ba3c6ccbd1d8b7c333b"},
{file = "grpcio_tools-1.70.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:1fa9a81621d7178498dedcf94eb8f276a7594327faf3dd5fd1935ce2819a2bdb"},
{file = "grpcio_tools-1.70.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:c6da2585c0950cdb650df1ff6d85b3fe31e22f8370b9ee11f8fe641d5b4bf096"},
{file = "grpcio_tools-1.70.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70234b592af17050ec30cf35894790cef52aeae87639efe6db854a7fa783cc8c"},
{file = "grpcio_tools-1.70.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c021b040d0a9f5bb96a725c4d2b95008aad127d6bed124a7bbe854973014f5b"},
{file = "grpcio_tools-1.70.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:114a42e566e5b16a47e98f7910a6c0074b37e2d1faacaae13222e463d0d0d43c"},
{file = "grpcio_tools-1.70.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:4cae365d7e3ba297256216a9a256458b286f75c64603f017972b3ad1ee374437"},
{file = "grpcio_tools-1.70.0-cp313-cp313-win32.whl", hash = "sha256:ae139a8d3ddd8353f62af3af018e99ebcd2f4a237bd319cb4b6f58dd608aaa54"},
{file = "grpcio_tools-1.70.0-cp313-cp313-win_amd64.whl", hash = "sha256:04bf30c0eb2741defe3ab6e0a6102b022d69cfd39d68fab9b954993ceca8d346"},
{file = "grpcio_tools-1.70.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:076f71c6d5adcf237ebca63f1ed51098293261dab9f301e3dfd180e896e5fa89"},
{file = "grpcio_tools-1.70.0-cp38-cp38-macosx_10_14_universal2.whl", hash = "sha256:d1fc2112e9c40167086e2e6a929b253e5281bffd070fab7cd1ae019317ffc11d"},
{file = "grpcio_tools-1.70.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:904f13d2d04f88178b09d8ef89549b90cbf8792b684a7c72540fc1a9887697e2"},
{file = "grpcio_tools-1.70.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1de6c71833d36fb8cc8ac10539681756dc2c5c67e5d4aa4d05adb91ecbdd8474"},
{file = "grpcio_tools-1.70.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ab788afced2d2c59bef86479967ce0b28485789a9f2cc43793bb7aa67f9528b"},
{file = "grpcio_tools-1.70.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:836293dcbb1e59fa52aa8aa890bd7a32a8eea7651cd614e96d86de4f3032fe73"},
{file = "grpcio_tools-1.70.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:740b3741d124c5f390dd50ad1c42c11788882baf3c202cd3e69adee0e3dde559"},
{file = "grpcio_tools-1.70.0-cp38-cp38-win32.whl", hash = "sha256:b9e4a12b862ba5e42d8028da311e8d4a2c307362659b2f4141d0f940f8c12b49"},
{file = "grpcio_tools-1.70.0-cp38-cp38-win_amd64.whl", hash = "sha256:fd04c93af460b1456cd12f8f85502503e1db6c4adc1b7d4bd775b12c1fd94fee"},
{file = "grpcio_tools-1.70.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:52d7e7ef11867fe7de577076b1f2ac6bf106b2325130e3de66f8c364c96ff332"},
{file = "grpcio_tools-1.70.0-cp39-cp39-macosx_10_14_universal2.whl", hash = "sha256:0f7ed0372afd9f5eb938334e84681396257015ab92e03de009aa3170e64b24d0"},
{file = "grpcio_tools-1.70.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:24a5b0328ffcfe0c4a9024f302545abdb8d6f24921409a5839f2879555b96fea"},
{file = "grpcio_tools-1.70.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9387b30f3b2f46942fb5718624d7421875a6ce458620d6e15817172d78db1e1a"},
{file = "grpcio_tools-1.70.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4545264e06e1cd7fb21b9447bb5126330bececb4bc626c98f793fda2fd910bf8"},
{file = "grpcio_tools-1.70.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:79b723ce30416e8e1d7ff271f97ade79aaf30309a595d80c377105c07f5b20fd"},
{file = "grpcio_tools-1.70.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1c0917dce12af04529606d437def83962d51c59dcde905746134222e94a2ab1b"},
{file = "grpcio_tools-1.70.0-cp39-cp39-win32.whl", hash = "sha256:5cb0baa52d4d44690fac6b1040197c694776a291a90e2d3c369064b4d5bc6642"},
{file = "grpcio_tools-1.70.0-cp39-cp39-win_amd64.whl", hash = "sha256:840ec536ab933db2ef8d5acaa6b712d0e9e8f397f62907c852ec50a3f69cdb78"},
{file = "grpcio_tools-1.70.0.tar.gz", hash = "sha256:e578fee7c1c213c8e471750d92631d00f178a15479fb2cb3b939a07fc125ccd3"},
]
[package.dependencies]
grpcio = ">=1.69.0"
grpcio = ">=1.70.0"
protobuf = ">=5.26.1,<6.0dev"
setuptools = "*"
@ -2465,17 +2465,17 @@ typing-extensions = ">=4.7"
[[package]]
name = "langchain-openai"
version = "0.3.1"
version = "0.3.2"
description = "An integration package connecting OpenAI and LangChain"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "langchain_openai-0.3.1-py3-none-any.whl", hash = "sha256:5cf2a1e115b12570158d89c22832fa381803c3e1e11d1eb781195c8d9e454bd5"},
{file = "langchain_openai-0.3.1.tar.gz", hash = "sha256:cce314f1437b2cad73e0ed2b55e74dc399bc1bbc43594c4448912fb51c5e4447"},
{file = "langchain_openai-0.3.2-py3-none-any.whl", hash = "sha256:8674183805e26d3ae3f78cc44f79fe0b2066f61e2de0e7e18be3b86f0d3b2759"},
{file = "langchain_openai-0.3.2.tar.gz", hash = "sha256:c2c80ac0208eb7cefdef96f6353b00fa217979ffe83f0a21cc8666001df828c1"},
]
[package.dependencies]
langchain-core = ">=0.3.30,<0.4.0"
langchain-core = ">=0.3.31,<0.4.0"
openai = ">=1.58.1,<2.0.0"
tiktoken = ">=0.7,<1"
@ -2537,13 +2537,13 @@ pytest = ["pytest (>=7.0.0)", "rich (>=13.9.4,<14.0.0)"]
[[package]]
name = "letta-client"
version = "0.1.17"
version = "0.1.19"
description = ""
optional = false
python-versions = "<4.0,>=3.8"
files = [
{file = "letta_client-0.1.17-py3-none-any.whl", hash = "sha256:b60996bb64c574ec0352a5256e5ce8c16bf72d462c244cf867afb5da2c49151f"},
{file = "letta_client-0.1.17.tar.gz", hash = "sha256:5172af77d5f6997b641219dabc68c925130372e47ca6718430e423a457ba2e8b"},
{file = "letta_client-0.1.19-py3-none-any.whl", hash = "sha256:45cdfff85a19c7cab676f27a99fd268ec535d79adde0ea828dce9d305fac2e55"},
{file = "letta_client-0.1.19.tar.gz", hash = "sha256:053fda535063ede4a74c2eff2af635524a19e10f4fca48b2649e85e3a6d19393"},
]
[package.dependencies]
@ -2571,19 +2571,19 @@ pydantic = ">=1.10"
[[package]]
name = "llama-index"
version = "0.12.12"
version = "0.12.13"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index-0.12.12-py3-none-any.whl", hash = "sha256:208f77dba5fd8268cacd3d56ec3ee33b0001d5b6ec623c5b91c755af7b08cfae"},
{file = "llama_index-0.12.12.tar.gz", hash = "sha256:d4e475726e342b1178736ae3ed93336fe114605e86431b6dfcb454a9e1f26e72"},
{file = "llama_index-0.12.13-py3-none-any.whl", hash = "sha256:0b285aa451ced6bd8da40df99068ac96badf8b5725c4edc29f2bce4da2ffd8bc"},
{file = "llama_index-0.12.13.tar.gz", hash = "sha256:1e39a397dcc51dabe280c121fd8d5451a6a84595233a8b26caa54d9b7ecf9ffc"},
]
[package.dependencies]
llama-index-agent-openai = ">=0.4.0,<0.5.0"
llama-index-cli = ">=0.4.0,<0.5.0"
llama-index-core = ">=0.12.12,<0.13.0"
llama-index-core = ">=0.12.13,<0.13.0"
llama-index-embeddings-openai = ">=0.3.0,<0.4.0"
llama-index-indices-managed-llama-cloud = ">=0.4.0"
llama-index-llms-openai = ">=0.3.0,<0.4.0"
@ -2628,13 +2628,13 @@ llama-index-llms-openai = ">=0.3.0,<0.4.0"
[[package]]
name = "llama-index-core"
version = "0.12.12"
version = "0.12.13"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index_core-0.12.12-py3-none-any.whl", hash = "sha256:cea491e87f65e6b775b5aef95720de302b85af1bdc67d779c4b09170a30e5b98"},
{file = "llama_index_core-0.12.12.tar.gz", hash = "sha256:068b755bbc681731336e822f5977d7608585e8f759c6293ebd812e2659316a37"},
{file = "llama_index_core-0.12.13-py3-none-any.whl", hash = "sha256:9708bb594bbddffd6ff0767242e49d8978d1ba60a2e62e071d9d123ad2f17e6f"},
{file = "llama_index_core-0.12.13.tar.gz", hash = "sha256:77af0161246ce1de38efc17cb6438dfff9e9558af00bcfac7dd4d0b7325efa4b"},
]
[package.dependencies]
@ -2755,13 +2755,13 @@ llama-index-program-openai = ">=0.3.0,<0.4.0"
[[package]]
name = "llama-index-readers-file"
version = "0.4.3"
version = "0.4.4"
description = "llama-index readers file integration"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index_readers_file-0.4.3-py3-none-any.whl", hash = "sha256:c669da967ea534e3af3660f9fd730c71c725288f5c57906bcce338414ebeee5c"},
{file = "llama_index_readers_file-0.4.3.tar.gz", hash = "sha256:07514bebed7ce431c1b3ef9279d09aa3d1bba8e342d661860a033355b98fb33a"},
{file = "llama_index_readers_file-0.4.4-py3-none-any.whl", hash = "sha256:01589a4895e2d4abad30294c9b0d2813520ee1f5164922ad92f11e64a1d65d6c"},
{file = "llama_index_readers_file-0.4.4.tar.gz", hash = "sha256:e076b3fa1e68eea1594d47cec1f64b384fb6067f2697ca8aae22b4a21ad27ca7"},
]
[package.dependencies]
@ -6441,4 +6441,4 @@ tests = ["wikipedia"]
[metadata]
lock-version = "2.0"
python-versions = "<3.14,>=3.10"
content-hash = "effb82094dcdc8c73c1c3e4277a7d3012f33ff7d8b4cf114f90b55df7f663587"
content-hash = "58f66b702bd791fcf73f48fa59a1bb0930370832427c1660ebbc81b9c58d1123"

View File

@ -78,6 +78,7 @@ llama-index-embeddings-openai = "^0.3.1"
e2b-code-interpreter = {version = "^1.0.3", optional = true}
anthropic = "^0.43.0"
letta_client = "^0.1.16"
openai = "^1.60.0"
colorama = "^0.4.6"

View File

@ -75,8 +75,10 @@ def test_ripple_edit(client, mock_e2b_api_key_none):
# limit=2000,
# )
# new_memory = Block(name="rethink_memory_block", label="rethink_memory_block", value="[empty]", limit=2000)
conversation_memory = BasicBlockMemory(blocks=[conversation_persona_block, conversation_human_block, fact_block, new_memory])
offline_memory = BasicBlockMemory(blocks=[offline_persona_block, offline_human_block, fact_block, new_memory])
# conversation_memory = BasicBlockMemory(blocks=[conversation_persona_block, conversation_human_block, fact_block, new_memory])
conversation_memory = BasicBlockMemory(blocks=[conversation_persona_block, conversation_human_block, fact_block])
# offline_memory = BasicBlockMemory(blocks=[offline_persona_block, offline_human_block, fact_block, new_memory])
offline_memory = BasicBlockMemory(blocks=[offline_persona_block, offline_human_block, fact_block])
conversation_agent = client.create_agent(
name="conversation_agent",
@ -86,6 +88,7 @@ def test_ripple_edit(client, mock_e2b_api_key_none):
embedding_config=EmbeddingConfig.default_config("text-embedding-ada-002"),
tool_ids=[send_message.id, trigger_rethink_memory_tool.id],
memory=conversation_memory,
block_ids=[new_memory.id],
include_base_tools=False,
)
assert conversation_agent is not None
@ -103,6 +106,7 @@ def test_ripple_edit(client, mock_e2b_api_key_none):
embedding_config=EmbeddingConfig.default_config("text-embedding-ada-002"),
tool_ids=[rethink_memory_tool.id, finish_rethinking_memory_tool.id],
tool_rules=[TerminalToolRule(tool_name=finish_rethinking_memory_tool.name)],
block_ids=[new_memory.id],
include_base_tools=False,
)
assert offline_memory_agent is not None

View File

@ -14,7 +14,7 @@ from letta.llm_api.helpers import calculate_summarizer_cutoff
from letta.schemas.embedding_config import EmbeddingConfig
from letta.schemas.enums import MessageRole
from letta.schemas.llm_config import LLMConfig
from letta.schemas.message import Message
from letta.schemas.message import Message, TextContent
from letta.settings import summarizer_settings
from letta.streaming_interface import StreamingRefreshCLIInterface
from tests.helpers.endpoints_helper import EMBEDDING_CONFIG_PATH
@ -55,7 +55,7 @@ def generate_message(role: str, text: str = None, tool_calls: List = None) -> Me
return Message(
id="message-" + str(uuid.uuid4()),
role=MessageRole(role),
text=text or f"{role} message text",
content=[TextContent(text=text or f"{role} message text")],
created_at=datetime.utcnow(),
tool_calls=tool_calls or [],
)

View File

@ -249,7 +249,7 @@ def test_agent_tags(client: Union[LocalClient, RESTClient]):
assert paginated_tags[1] == "agent2"
# Test pagination with cursor
next_page_tags = client.get_tags(cursor="agent2", limit=2)
next_page_tags = client.get_tags(after="agent2", limit=2)
assert len(next_page_tags) == 2
assert next_page_tags[0] == "agent3"
assert next_page_tags[1] == "development"
@ -654,7 +654,7 @@ def test_agent_listing(client: Union[LocalClient, RESTClient], agent, search_age
assert len(first_page) == 1
first_agent = first_page[0]
second_page = client.list_agents(query_text="search agent", cursor=first_agent.id, limit=1) # Use agent ID as cursor
second_page = client.list_agents(query_text="search agent", after=first_agent.id, limit=1) # Use agent ID as cursor
assert len(second_page) == 1
assert second_page[0].id != first_agent.id

View File

@ -369,7 +369,7 @@ def test_list_files_pagination(client: Union[LocalClient, RESTClient], agent: Ag
assert files_a[0].source_id == source.id
# Use the cursor from response_a to get the remaining file
files_b = client.list_files_from_source(source.id, limit=1, cursor=files_a[-1].id)
files_b = client.list_files_from_source(source.id, limit=1, after=files_a[-1].id)
assert len(files_b) == 1
assert files_b[0].source_id == source.id
@ -377,7 +377,7 @@ def test_list_files_pagination(client: Union[LocalClient, RESTClient], agent: Ag
assert files_a[0].file_name != files_b[0].file_name
# Use the cursor from response_b to list files, should be empty
files = client.list_files_from_source(source.id, limit=1, cursor=files_b[-1].id)
files = client.list_files_from_source(source.id, limit=1, after=files_b[-1].id)
assert len(files) == 0 # Should be empty
@ -628,7 +628,7 @@ def test_initial_message_sequence(client: Union[LocalClient, RESTClient], agent:
empty_agent_state = client.create_agent(name="test-empty-message-sequence", initial_message_sequence=[])
cleanup_agents.append(empty_agent_state.id)
custom_sequence = [MessageCreate(**{"text": "Hello, how are you?", "role": MessageRole.user})]
custom_sequence = [MessageCreate(**{"content": "Hello, how are you?", "role": MessageRole.user})]
custom_agent_state = client.create_agent(name="test-custom-message-sequence", initial_message_sequence=custom_sequence)
cleanup_agents.append(custom_agent_state.id)
assert custom_agent_state.message_ids is not None
@ -637,7 +637,7 @@ def test_initial_message_sequence(client: Union[LocalClient, RESTClient], agent:
), f"Expected {len(custom_sequence) + 1} messages, got {len(custom_agent_state.message_ids)}"
# assert custom_agent_state.message_ids[1:] == [msg.id for msg in custom_sequence]
# shoule be contained in second message (after system message)
assert custom_sequence[0].text in client.get_in_context_messages(custom_agent_state.id)[1].text
assert custom_sequence[0].content in client.get_in_context_messages(custom_agent_state.id)[1].text
def test_add_and_manage_tags_for_agent(client: Union[LocalClient, RESTClient], agent: AgentState):

View File

@ -447,7 +447,7 @@ def comprehensive_test_agent_fixture(server: SyncServer, default_user, print_too
description="test_description",
metadata={"test_key": "test_value"},
tool_rules=[InitToolRule(tool_name=print_tool.name)],
initial_message_sequence=[MessageCreate(role=MessageRole.user, text="hello world")],
initial_message_sequence=[MessageCreate(role=MessageRole.user, content="hello world")],
tool_exec_environment_variables={"test_env_var_key_a": "test_env_var_value_a", "test_env_var_key_b": "test_env_var_value_b"},
)
created_agent = server.agent_manager.create_agent(
@ -549,7 +549,7 @@ def test_create_agent_passed_in_initial_messages(server: SyncServer, default_use
block_ids=[default_block.id],
tags=["a", "b"],
description="test_description",
initial_message_sequence=[MessageCreate(role=MessageRole.user, text="hello world")],
initial_message_sequence=[MessageCreate(role=MessageRole.user, content="hello world")],
)
agent_state = server.agent_manager.create_agent(
create_agent_request,
@ -562,7 +562,7 @@ def test_create_agent_passed_in_initial_messages(server: SyncServer, default_use
assert create_agent_request.memory_blocks[0].value in init_messages[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].text in init_messages[1].text
assert create_agent_request.initial_message_sequence[0].content in init_messages[1].text
def test_create_agent_default_initial_message(server: SyncServer, default_user, default_block):
@ -914,11 +914,18 @@ def test_list_agents_by_tags_pagination(server: SyncServer, default_user, defaul
# Get second page using cursor
second_page = server.agent_manager.list_agents(
tags=["pagination_test"], match_all_tags=True, actor=default_user, cursor=first_agent_id, limit=1
tags=["pagination_test"], match_all_tags=True, actor=default_user, after=first_agent_id, limit=1
)
assert len(second_page) == 1
assert second_page[0].id != first_agent_id
# Get previous page using before
prev_page = server.agent_manager.list_agents(
tags=["pagination_test"], match_all_tags=True, actor=default_user, before=second_page[0].id, limit=1
)
assert len(prev_page) == 1
assert prev_page[0].id == first_agent_id
# Verify we got both agents with no duplicates
all_ids = {first_page[0].id, second_page[0].id}
assert len(all_ids) == 2
@ -980,10 +987,20 @@ def test_list_agents_query_text_pagination(server: SyncServer, default_user, def
first_agent_id = first_page[0].id
# Get second page using cursor
second_page = server.agent_manager.list_agents(actor=default_user, query_text="search agent", cursor=first_agent_id, limit=1)
second_page = server.agent_manager.list_agents(actor=default_user, query_text="search agent", after=first_agent_id, limit=1)
assert len(second_page) == 1
assert second_page[0].id != first_agent_id
# Test before and after
all_agents = server.agent_manager.list_agents(actor=default_user, query_text="agent")
assert len(all_agents) == 3
first_agent, second_agent, third_agent = all_agents
middle_agent = server.agent_manager.list_agents(
actor=default_user, query_text="search agent", before=third_agent.id, after=first_agent.id
)
assert len(middle_agent) == 1
assert middle_agent[0].id == second_agent.id
# Verify we got both search agents with no duplicates
all_ids = {first_page[0].id, second_page[0].id}
assert len(all_ids) == 2
@ -1139,6 +1156,10 @@ def test_detach_block(server: SyncServer, sarah_agent, default_block, default_us
agent = server.agent_manager.get_agent_by_id(sarah_agent.id, actor=default_user)
assert len(agent.memory.blocks) == 0
# Check that block still exists
block = server.block_manager.get_block_by_id(block_id=default_block.id, actor=default_user)
assert block
def test_detach_nonexistent_block(server: SyncServer, sarah_agent, default_user):
"""Test detaching a block that isn't attached."""
@ -1232,12 +1253,33 @@ def test_agent_list_passages_pagination(server, default_user, sarah_agent, agent
assert len(first_page) == 2
second_page = server.agent_manager.list_passages(
actor=default_user, agent_id=sarah_agent.id, cursor=first_page[-1].id, limit=2, ascending=True
actor=default_user, agent_id=sarah_agent.id, after=first_page[-1].id, limit=2, ascending=True
)
assert len(second_page) == 2
assert first_page[-1].id != second_page[0].id
assert first_page[-1].created_at <= second_page[0].created_at
"""
[1] [2]
* * | * *
[mid]
* | * * | *
"""
middle_page = server.agent_manager.list_passages(
actor=default_user, agent_id=sarah_agent.id, before=second_page[-1].id, after=first_page[0].id, ascending=True
)
assert len(middle_page) == 2
assert middle_page[0].id == first_page[-1].id
assert middle_page[1].id == second_page[0].id
middle_page_desc = server.agent_manager.list_passages(
actor=default_user, agent_id=sarah_agent.id, before=second_page[-1].id, after=first_page[0].id, ascending=False
)
assert len(middle_page_desc) == 2
assert middle_page_desc[0].id == second_page[0].id
assert middle_page_desc[1].id == first_page[-1].id
def test_agent_list_passages_text_search(server, default_user, sarah_agent, agent_passages_setup):
"""Test text search functionality of agent passages"""
@ -1402,11 +1444,11 @@ def test_list_organizations_pagination(server: SyncServer):
orgs_x = server.organization_manager.list_organizations(limit=1)
assert len(orgs_x) == 1
orgs_y = server.organization_manager.list_organizations(cursor=orgs_x[0].id, limit=1)
orgs_y = server.organization_manager.list_organizations(after=orgs_x[0].id, limit=1)
assert len(orgs_y) == 1
assert orgs_y[0].name != orgs_x[0].name
orgs = server.organization_manager.list_organizations(cursor=orgs_y[0].id, limit=1)
orgs = server.organization_manager.list_organizations(after=orgs_y[0].id, limit=1)
assert len(orgs) == 0
@ -1789,7 +1831,7 @@ def test_message_get_by_id(server: SyncServer, hello_world_message_fixture, defa
def test_message_update(server: SyncServer, hello_world_message_fixture, default_user, other_user):
"""Test updating a message"""
new_text = "Updated text"
updated = server.message_manager.update_message_by_id(hello_world_message_fixture.id, MessageUpdate(text=new_text), actor=other_user)
updated = server.message_manager.update_message_by_id(hello_world_message_fixture.id, MessageUpdate(content=new_text), actor=other_user)
assert updated is not None
assert updated.text == new_text
retrieved = server.message_manager.get_message_by_id(hello_world_message_fixture.id, actor=default_user)
@ -1871,7 +1913,7 @@ def test_message_listing_cursor(server: SyncServer, hello_world_message_fixture,
"""Test cursor-based pagination functionality"""
create_test_messages(server, hello_world_message_fixture, default_user)
# Make sure there are 5 messages
# Make sure there are 6 messages
assert server.message_manager.size(actor=default_user, role=MessageRole.user) == 6
# Get first page
@ -1882,11 +1924,28 @@ def test_message_listing_cursor(server: SyncServer, hello_world_message_fixture,
# Get second page
second_page = server.message_manager.list_user_messages_for_agent(
agent_id=sarah_agent.id, actor=default_user, cursor=last_id_on_first_page, limit=3
agent_id=sarah_agent.id, actor=default_user, after=last_id_on_first_page, limit=3
)
assert len(second_page) == 3 # Should have 2 remaining messages
assert len(second_page) == 3 # Should have 3 remaining messages
assert all(r1.id != r2.id for r1 in first_page for r2 in second_page)
# Get the middle
middle_page = server.message_manager.list_user_messages_for_agent(
agent_id=sarah_agent.id, actor=default_user, before=second_page[1].id, after=first_page[0].id
)
assert len(middle_page) == 3
assert middle_page[0].id == first_page[1].id
assert middle_page[1].id == first_page[-1].id
assert middle_page[-1].id == second_page[0].id
middle_page_desc = server.message_manager.list_user_messages_for_agent(
agent_id=sarah_agent.id, actor=default_user, before=second_page[1].id, after=first_page[0].id, ascending=False
)
assert len(middle_page_desc) == 3
assert middle_page_desc[0].id == second_page[0].id
assert middle_page_desc[1].id == first_page[-1].id
assert middle_page_desc[-1].id == first_page[1].id
def test_message_listing_filtering(server: SyncServer, hello_world_message_fixture, default_user, sarah_agent):
"""Test filtering messages by agent ID"""
@ -2026,6 +2085,46 @@ def test_delete_block(server: SyncServer, default_user):
assert len(blocks) == 0
def test_delete_block_detaches_from_agent(server: SyncServer, sarah_agent, default_user):
# Create and delete a block
block = server.block_manager.create_or_update_block(PydanticBlock(label="human", value="Sample content"), actor=default_user)
agent_state = server.agent_manager.attach_block(agent_id=sarah_agent.id, block_id=block.id, actor=default_user)
# Check that block has been attached
assert block.id in [b.id for b in agent_state.memory.blocks]
# Now attempt to delete the block
server.block_manager.delete_block(block_id=block.id, actor=default_user)
# Verify that the block was deleted
blocks = server.block_manager.get_blocks(actor=default_user)
assert len(blocks) == 0
# Check that block has been detached too
agent_state = server.agent_manager.get_agent_by_id(agent_id=sarah_agent.id, actor=default_user)
assert not (block.id in [b.id for b in agent_state.memory.blocks])
def test_get_agents_for_block(server: SyncServer, sarah_agent, charles_agent, default_user):
# Create and delete a block
block = server.block_manager.create_or_update_block(PydanticBlock(label="alien", value="Sample content"), actor=default_user)
sarah_agent = server.agent_manager.attach_block(agent_id=sarah_agent.id, block_id=block.id, actor=default_user)
charles_agent = server.agent_manager.attach_block(agent_id=charles_agent.id, block_id=block.id, actor=default_user)
# Check that block has been attached to both
assert block.id in [b.id for b in sarah_agent.memory.blocks]
assert block.id in [b.id for b in charles_agent.memory.blocks]
# Get the agents for that block
agent_states = server.block_manager.get_agents_for_block(block_id=block.id, actor=default_user)
assert len(agent_states) == 2
# Check both agents are in the list
agent_state_ids = [a.id for a in agent_states]
assert sarah_agent.id in agent_state_ids
assert charles_agent.id in agent_state_ids
# ======================================================================================================================
# SourceManager Tests - Sources
# ======================================================================================================================
@ -2118,7 +2217,7 @@ def test_list_sources(server: SyncServer, default_user):
assert len(paginated_sources) == 1
# Ensure cursor-based pagination works
next_page = server.source_manager.list_sources(actor=default_user, cursor=paginated_sources[-1].id, limit=1)
next_page = server.source_manager.list_sources(actor=default_user, after=paginated_sources[-1].id, limit=1)
assert len(next_page) == 1
assert next_page[0].name != paginated_sources[0].name
@ -2218,7 +2317,7 @@ def test_list_files(server: SyncServer, default_user, default_source):
assert len(paginated_files) == 1
# Ensure cursor-based pagination works
next_page = server.source_manager.list_files(source_id=default_source.id, actor=default_user, cursor=paginated_files[-1].id, limit=1)
next_page = server.source_manager.list_files(source_id=default_source.id, actor=default_user, after=paginated_files[-1].id, limit=1)
assert len(next_page) == 1
assert next_page[0].file_name != paginated_files[0].file_name
@ -2316,7 +2415,7 @@ def test_list_sandbox_configs(server: SyncServer, default_user):
paginated_configs = server.sandbox_config_manager.list_sandbox_configs(actor=default_user, limit=1)
assert len(paginated_configs) == 1
next_page = server.sandbox_config_manager.list_sandbox_configs(actor=default_user, cursor=paginated_configs[-1].id, limit=1)
next_page = server.sandbox_config_manager.list_sandbox_configs(actor=default_user, after=paginated_configs[-1].id, limit=1)
assert len(next_page) == 1
assert next_page[0].id != paginated_configs[0].id
@ -2387,7 +2486,7 @@ def test_list_sandbox_env_vars(server: SyncServer, sandbox_config_fixture, defau
assert len(paginated_env_vars) == 1
next_page = server.sandbox_config_manager.list_sandbox_env_vars(
sandbox_config_id=sandbox_config_fixture.id, actor=default_user, cursor=paginated_env_vars[-1].id, limit=1
sandbox_config_id=sandbox_config_fixture.id, actor=default_user, after=paginated_env_vars[-1].id, limit=1
)
assert len(next_page) == 1
assert next_page[0].id != paginated_env_vars[0].id
@ -2541,11 +2640,46 @@ def test_list_jobs_pagination(server: SyncServer, default_user):
# List jobs with a limit
jobs = server.job_manager.list_jobs(actor=default_user, limit=5)
# Assertions to check pagination
assert len(jobs) == 5
assert all(job.user_id == default_user.id for job in jobs)
# Test cursor-based pagination
first_page = server.job_manager.list_jobs(actor=default_user, limit=3, ascending=True) # [J0, J1, J2]
assert len(first_page) == 3
assert first_page[0].created_at <= first_page[1].created_at <= first_page[2].created_at
last_page = server.job_manager.list_jobs(actor=default_user, limit=3, ascending=False) # [J9, J8, J7]
assert len(last_page) == 3
assert last_page[0].created_at >= last_page[1].created_at >= last_page[2].created_at
first_page_ids = set(job.id for job in first_page)
last_page_ids = set(job.id for job in last_page)
assert first_page_ids.isdisjoint(last_page_ids)
# Test middle page using both before and after
middle_page = server.job_manager.list_jobs(
actor=default_user, before=last_page[-1].id, after=first_page[-1].id, ascending=True
) # [J3, J4, J5, J6]
assert len(middle_page) == 4 # Should include jobs between first and second page
head_tail_jobs = first_page_ids.union(last_page_ids)
assert all(job.id not in head_tail_jobs for job in middle_page)
# Test descending order
middle_page_desc = server.job_manager.list_jobs(
actor=default_user, before=last_page[-1].id, after=first_page[-1].id, ascending=False
) # [J6, J5, J4, J3]
assert len(middle_page_desc) == 4
assert middle_page_desc[0].id == middle_page[-1].id
assert middle_page_desc[1].id == middle_page[-2].id
assert middle_page_desc[2].id == middle_page[-3].id
assert middle_page_desc[3].id == middle_page[-4].id
# BONUS
job_7 = last_page[-1].id
earliest_jobs = server.job_manager.list_jobs(actor=default_user, ascending=False, before=job_7)
assert len(earliest_jobs) == 7
assert all(j.id not in last_page_ids for j in earliest_jobs)
assert all(earliest_jobs[i].created_at >= earliest_jobs[i + 1].created_at for i in range(len(earliest_jobs) - 1))
def test_list_jobs_by_status(server: SyncServer, default_user):
"""Test listing jobs filtered by status."""
@ -2660,15 +2794,81 @@ def test_job_messages_pagination(server: SyncServer, default_run, default_user,
assert messages[1].id == message_ids[1]
# Test pagination with cursor
messages = server.job_manager.get_job_messages(
first_page = server.job_manager.get_job_messages(
job_id=default_run.id,
actor=default_user,
cursor=message_ids[1],
limit=2,
ascending=True, # [M0, M1]
)
assert len(messages) == 2
assert messages[0].id == message_ids[2]
assert messages[1].id == message_ids[3]
assert len(first_page) == 2
assert first_page[0].id == message_ids[0]
assert first_page[1].id == message_ids[1]
assert first_page[0].created_at <= first_page[1].created_at
last_page = server.job_manager.get_job_messages(
job_id=default_run.id,
actor=default_user,
limit=2,
ascending=False, # [M4, M3]
)
assert len(last_page) == 2
assert last_page[0].id == message_ids[4]
assert last_page[1].id == message_ids[3]
assert last_page[0].created_at >= last_page[1].created_at
first_page_ids = set(msg.id for msg in first_page)
last_page_ids = set(msg.id for msg in last_page)
assert first_page_ids.isdisjoint(last_page_ids)
# Test middle page using both before and after
middle_page = server.job_manager.get_job_messages(
job_id=default_run.id,
actor=default_user,
before=last_page[-1].id, # M3
after=first_page[0].id, # M0
ascending=True, # [M1, M2]
)
assert len(middle_page) == 2 # Should include message between first and last pages
assert middle_page[0].id == message_ids[1]
assert middle_page[1].id == message_ids[2]
head_tail_msgs = first_page_ids.union(last_page_ids)
assert middle_page[1].id not in head_tail_msgs
assert middle_page[0].id in first_page_ids
# Test descending order for middle page
middle_page = server.job_manager.get_job_messages(
job_id=default_run.id,
actor=default_user,
before=last_page[-1].id, # M3
after=first_page[0].id, # M0
ascending=False, # [M2, M1]
)
assert len(middle_page) == 2 # Should include message between first and last pages
assert middle_page[0].id == message_ids[2]
assert middle_page[1].id == message_ids[1]
# Test getting earliest messages
msg_3 = last_page[-1].id
earliest_msgs = server.job_manager.get_job_messages(
job_id=default_run.id,
actor=default_user,
ascending=False,
before=msg_3, # Get messages after M3 in descending order
)
assert len(earliest_msgs) == 3 # Should get M2, M1, M0
assert all(m.id not in last_page_ids for m in earliest_msgs)
assert earliest_msgs[0].created_at > earliest_msgs[1].created_at > earliest_msgs[2].created_at
# Test getting earliest messages with ascending order
earliest_msgs_ascending = server.job_manager.get_job_messages(
job_id=default_run.id,
actor=default_user,
ascending=True,
before=msg_3, # Get messages before M3 in ascending order
)
assert len(earliest_msgs_ascending) == 3 # Should get M0, M1, M2
assert all(m.id not in last_page_ids for m in earliest_msgs_ascending)
assert earliest_msgs_ascending[0].created_at < earliest_msgs_ascending[1].created_at < earliest_msgs_ascending[2].created_at
def test_job_messages_ordering(server: SyncServer, default_run, default_user, sarah_agent):
@ -2800,7 +3000,7 @@ def test_job_messages_filter(server: SyncServer, default_run, default_user, sara
assert len(limited_messages) == 2
def test_get_run_messages_cursor(server: SyncServer, default_user: PydanticUser, sarah_agent):
def test_get_run_messages(server: SyncServer, default_user: PydanticUser, sarah_agent):
"""Test getting messages for a run with request config."""
# Create a run with custom request config
run = server.job_manager.create_job(
@ -2835,7 +3035,7 @@ def test_get_run_messages_cursor(server: SyncServer, default_user: PydanticUser,
server.job_manager.add_message_to_job(job_id=run.id, message_id=created_msg.id, actor=default_user)
# Get messages and verify they're converted correctly
result = server.job_manager.get_run_messages_cursor(run_id=run.id, actor=default_user)
result = server.job_manager.get_run_messages(run_id=run.id, actor=default_user)
# Verify correct number of messages. Assistant messages should be parsed
assert len(result) == 6
@ -2995,7 +3195,7 @@ def test_list_tags(server: SyncServer, default_user, default_organization):
assert limited_tags == tags[:2] # Should return first 2 tags
# Test pagination with cursor
cursor_tags = server.agent_manager.list_tags(actor=default_user, cursor="beta")
cursor_tags = server.agent_manager.list_tags(actor=default_user, after="beta")
assert cursor_tags == ["delta", "epsilon", "gamma"] # Tags after "beta"
# Test text search

View File

@ -96,7 +96,7 @@ def test_shared_blocks(client):
messages=[
MessageCreate(
role="user",
text="my name is actually charles",
content="my name is actually charles",
)
],
)
@ -109,7 +109,7 @@ def test_shared_blocks(client):
messages=[
MessageCreate(
role="user",
text="whats my name?",
content="whats my name?",
)
],
)
@ -338,7 +338,7 @@ def test_messages(client, agent):
messages=[
MessageCreate(
role="user",
text="Test message",
content="Test message",
),
],
)
@ -358,7 +358,7 @@ def test_send_system_message(client, agent):
messages=[
MessageCreate(
role="system",
text="Event occurred: The user just logged off.",
content="Event occurred: The user just logged off.",
),
],
)
@ -387,7 +387,7 @@ def test_function_return_limit(client, agent):
messages=[
MessageCreate(
role="user",
text="call the big_return function",
content="call the big_return function",
),
],
config=LettaRequestConfig(use_assistant_message=False),
@ -423,7 +423,7 @@ def test_function_always_error(client, agent):
messages=[
MessageCreate(
role="user",
text="call the always_error function",
content="call the always_error function",
),
],
config=LettaRequestConfig(use_assistant_message=False),
@ -454,7 +454,7 @@ async def test_send_message_parallel(client, agent):
messages=[
MessageCreate(
role="user",
text=message,
content=message,
),
],
)
@ -489,7 +489,7 @@ def test_send_message_async(client, agent):
messages=[
MessageCreate(
role="user",
text=test_message,
content=test_message,
),
],
config=LettaRequestConfig(use_assistant_message=False),

View File

@ -1,5 +1,6 @@
import json
import os
import shutil
import uuid
import warnings
from typing import List, Tuple
@ -13,6 +14,7 @@ from letta.orm import Provider, Step
from letta.schemas.block import CreateBlock
from letta.schemas.enums import MessageRole
from letta.schemas.letta_message import LettaMessage, ReasoningMessage, SystemMessage, ToolCallMessage, ToolReturnMessage, UserMessage
from letta.schemas.llm_config import LLMConfig
from letta.schemas.providers import Provider as PydanticProvider
from letta.schemas.user import User
@ -330,7 +332,7 @@ def agent_id(server, user_id, base_tools):
name="test_agent",
tool_ids=[t.id for t in base_tools],
memory_blocks=[],
model="openai/gpt-4",
model="openai/gpt-4o",
embedding="openai/text-embedding-ada-002",
),
actor=actor,
@ -351,7 +353,7 @@ def other_agent_id(server, user_id, base_tools):
name="test_agent_other",
tool_ids=[t.id for t in base_tools],
memory_blocks=[],
model="openai/gpt-4",
model="openai/gpt-4o",
embedding="openai/text-embedding-ada-002",
),
actor=actor,
@ -392,7 +394,7 @@ def test_user_message_memory(server, user, agent_id):
@pytest.mark.order(3)
def test_load_data(server, user, agent_id):
# create source
passages_before = server.agent_manager.list_passages(actor=user, agent_id=agent_id, cursor=None, limit=10000)
passages_before = server.agent_manager.list_passages(actor=user, agent_id=agent_id, after=None, limit=10000)
assert len(passages_before) == 0
source = server.source_manager.create_source(
@ -414,7 +416,7 @@ def test_load_data(server, user, agent_id):
server.agent_manager.attach_source(agent_id=agent_id, source_id=source.id, actor=user)
# check archival memory size
passages_after = server.agent_manager.list_passages(actor=user, agent_id=agent_id, cursor=None, limit=10000)
passages_after = server.agent_manager.list_passages(actor=user, agent_id=agent_id, after=None, limit=10000)
assert len(passages_after) == 5
@ -426,25 +428,25 @@ def test_save_archival_memory(server, user_id, agent_id):
@pytest.mark.order(4)
def test_user_message(server, user, agent_id):
# add data into recall memory
server.user_message(user_id=user.id, agent_id=agent_id, message="Hello?")
# server.user_message(user_id=user_id, agent_id=agent_id, message="Hello?")
# server.user_message(user_id=user_id, agent_id=agent_id, message="Hello?")
# server.user_message(user_id=user_id, agent_id=agent_id, message="Hello?")
# server.user_message(user_id=user_id, agent_id=agent_id, message="Hello?")
response = server.user_message(user_id=user.id, agent_id=agent_id, message="What's up?")
assert response.step_count == 1
assert response.completion_tokens > 0
assert response.prompt_tokens > 0
assert response.total_tokens > 0
@pytest.mark.order(5)
def test_get_recall_memory(server, org_id, user, agent_id):
# test recall memory cursor pagination
actor = user
messages_1 = server.get_agent_recall_cursor(user_id=user.id, agent_id=agent_id, limit=2)
messages_1 = server.get_agent_recall(user_id=user.id, agent_id=agent_id, limit=2)
cursor1 = messages_1[-1].id
messages_2 = server.get_agent_recall_cursor(user_id=user.id, agent_id=agent_id, after=cursor1, limit=1000)
messages_3 = server.get_agent_recall_cursor(user_id=user.id, agent_id=agent_id, limit=1000)
messages_2 = server.get_agent_recall(user_id=user.id, agent_id=agent_id, after=cursor1, limit=1000)
messages_3 = server.get_agent_recall(user_id=user.id, agent_id=agent_id, limit=1000)
messages_3[-1].id
assert messages_3[-1].created_at >= messages_3[0].created_at
assert len(messages_3) == len(messages_1) + len(messages_2)
messages_4 = server.get_agent_recall_cursor(user_id=user.id, agent_id=agent_id, reverse=True, before=cursor1)
messages_4 = server.get_agent_recall(user_id=user.id, agent_id=agent_id, reverse=True, before=cursor1)
assert len(messages_4) == 1
# test in-context message ids
@ -475,7 +477,7 @@ def test_get_archival_memory(server, user, agent_id):
actor=actor,
agent_id=agent_id,
ascending=False,
cursor=cursor1,
before=cursor1,
)
# List all 5
@ -497,11 +499,11 @@ def test_get_archival_memory(server, user, agent_id):
passage_1 = server.agent_manager.list_passages(actor=actor, agent_id=agent_id, limit=1, ascending=True)
assert len(passage_1) == 1
assert passage_1[0].text == "alpha"
passage_2 = server.agent_manager.list_passages(actor=actor, agent_id=agent_id, cursor=earliest.id, limit=1000, ascending=True)
passage_2 = server.agent_manager.list_passages(actor=actor, agent_id=agent_id, after=earliest.id, limit=1000, ascending=True)
assert len(passage_2) in [4, 5] # NOTE: exact size seems non-deterministic, so loosen test
assert all("alpha" not in passage.text for passage in passage_2)
# test safe empty return
passage_none = server.agent_manager.list_passages(actor=actor, agent_id=agent_id, cursor=latest.id, limit=1000, ascending=True)
passage_none = server.agent_manager.list_passages(actor=actor, agent_id=agent_id, after=latest.id, limit=1000, ascending=True)
assert len(passage_none) == 0
@ -550,7 +552,7 @@ def test_delete_agent_same_org(server: SyncServer, org_id: str, user: User):
request=CreateAgent(
name="nonexistent_tools_agent",
memory_blocks=[],
model="openai/gpt-4",
model="openai/gpt-4o",
embedding="openai/text-embedding-ada-002",
),
actor=user,
@ -563,6 +565,63 @@ def test_delete_agent_same_org(server: SyncServer, org_id: str, user: User):
server.agent_manager.delete_agent(agent_state.id, actor=another_user)
def test_read_local_llm_configs(server: SyncServer, user: User):
configs_base_dir = os.path.join(os.path.expanduser("~"), ".letta", "llm_configs")
clean_up_dir = False
if not os.path.exists(configs_base_dir):
os.makedirs(configs_base_dir)
clean_up_dir = True
try:
sample_config = LLMConfig(
model="my-custom-model",
model_endpoint_type="openai",
model_endpoint="https://api.openai.com/v1",
context_window=8192,
handle="caren/my-custom-model",
)
config_filename = f"custom_llm_config_{uuid.uuid4().hex}.json"
config_filepath = os.path.join(configs_base_dir, config_filename)
with open(config_filepath, "w") as f:
json.dump(sample_config.model_dump(), f)
# Call list_llm_models
assert os.path.exists(configs_base_dir)
llm_models = server.list_llm_models()
# Assert that the config is in the returned models
assert any(
model.model == "my-custom-model"
and model.model_endpoint_type == "openai"
and model.model_endpoint == "https://api.openai.com/v1"
and model.context_window == 8192
and model.handle == "caren/my-custom-model"
for model in llm_models
), "Custom LLM config not found in list_llm_models result"
# Try to use in agent creation
context_window_override = 4000
agent = server.create_agent(
request=CreateAgent(
model="caren/my-custom-model",
context_window_limit=context_window_override,
embedding="openai/text-embedding-ada-002",
),
actor=user,
)
assert agent.llm_config.model == sample_config.model
assert agent.llm_config.model_endpoint == sample_config.model_endpoint
assert agent.llm_config.model_endpoint_type == sample_config.model_endpoint_type
assert agent.llm_config.context_window == context_window_override
assert agent.llm_config.handle == sample_config.handle
finally:
os.remove(config_filepath)
if clean_up_dir:
shutil.rmtree(configs_base_dir)
def _test_get_messages_letta_format(
server,
user,
@ -571,7 +630,7 @@ def _test_get_messages_letta_format(
):
"""Test mapping between messages and letta_messages with reverse=False."""
messages = server.get_agent_recall_cursor(
messages = server.get_agent_recall(
user_id=user.id,
agent_id=agent_id,
limit=1000,
@ -580,7 +639,7 @@ def _test_get_messages_letta_format(
)
assert all(isinstance(m, Message) for m in messages)
letta_messages = server.get_agent_recall_cursor(
letta_messages = server.get_agent_recall(
user_id=user.id,
agent_id=agent_id,
limit=1000,
@ -652,12 +711,12 @@ def _test_get_messages_letta_format(
elif message.role == MessageRole.user:
assert isinstance(letta_message, UserMessage)
assert message.text == letta_message.message
assert message.text == letta_message.content
letta_message_index += 1
elif message.role == MessageRole.system:
assert isinstance(letta_message, SystemMessage)
assert message.text == letta_message.message
assert message.text == letta_message.content
letta_message_index += 1
elif message.role == MessageRole.tool:
@ -861,7 +920,7 @@ def test_memory_rebuild_count(server, user, mock_e2b_api_key_none, base_tools, b
CreateBlock(label="human", value="The human's name is Bob."),
CreateBlock(label="persona", value="My name is Alice."),
],
model="openai/gpt-4",
model="openai/gpt-4o",
embedding="openai/text-embedding-ada-002",
),
actor=actor,
@ -871,7 +930,7 @@ def test_memory_rebuild_count(server, user, mock_e2b_api_key_none, base_tools, b
def count_system_messages_in_recall() -> Tuple[int, List[LettaMessage]]:
# At this stage, there should only be 1 system message inside of recall storage
letta_messages = server.get_agent_recall_cursor(
letta_messages = server.get_agent_recall(
user_id=user.id,
agent_id=agent_state.id,
limit=1000,
@ -1049,7 +1108,7 @@ def test_add_remove_tools_update_agent(server: SyncServer, user_id: str, base_to
CreateBlock(label="human", value="The human's name is Bob."),
CreateBlock(label="persona", value="My name is Alice."),
],
model="openai/gpt-4",
model="openai/gpt-4o",
embedding="openai/text-embedding-ada-002",
include_base_tools=False,
),
@ -1133,7 +1192,7 @@ def test_messages_with_provider_override(server: SyncServer, user_id: str):
usage = server.user_message(user_id=actor.id, agent_id=agent.id, message="Test message")
assert usage, "Sending message failed"
get_messages_response = server.message_manager.list_messages_for_agent(agent_id=agent.id, actor=actor, cursor=existing_messages[-1].id)
get_messages_response = server.message_manager.list_messages_for_agent(agent_id=agent.id, actor=actor, after=existing_messages[-1].id)
assert len(get_messages_response) > 0, "Retrieving messages failed"
step_ids = set([msg.step_id for msg in get_messages_response])
@ -1160,7 +1219,7 @@ def test_messages_with_provider_override(server: SyncServer, user_id: str):
usage = server.user_message(user_id=actor.id, agent_id=agent.id, message="Test message")
assert usage, "Sending message failed"
get_messages_response = server.message_manager.list_messages_for_agent(agent_id=agent.id, actor=actor, cursor=existing_messages[-1].id)
get_messages_response = server.message_manager.list_messages_for_agent(agent_id=agent.id, actor=actor, after=existing_messages[-1].id)
assert len(get_messages_response) > 0, "Retrieving messages failed"
step_ids = set([msg.step_id for msg in get_messages_response])
@ -1179,3 +1238,12 @@ def test_messages_with_provider_override(server: SyncServer, user_id: str):
assert completion_tokens == usage.completion_tokens
assert prompt_tokens == usage.prompt_tokens
assert total_tokens == usage.total_tokens
def test_unique_handles_for_provider_configs(server: SyncServer):
models = server.list_llm_models()
model_handles = [model.handle for model in models]
assert sorted(model_handles) == sorted(list(set(model_handles))), "All models should have unique handles"
embeddings = server.list_embedding_models()
embedding_handles = [embedding.handle for embedding in embeddings]
assert sorted(embedding_handles) == sorted(list(set(embedding_handles))), "All embeddings should have unique handles"

View File

@ -6,6 +6,7 @@ from composio.client.collections import ActionModel, ActionParametersModel, Acti
from fastapi.testclient import TestClient
from letta.orm.errors import NoResultFound
from letta.schemas.block import Block, BlockUpdate, CreateBlock
from letta.schemas.message import UserMessage
from letta.schemas.tool import ToolCreate, ToolUpdate
from letta.server.rest_api.app import app
@ -323,14 +324,14 @@ def test_get_run_messages(client, mock_sync_server):
UserMessage(
id=f"message-{i:08x}",
date=current_time,
message=f"Test message {i}",
content=f"Test message {i}",
)
for i in range(2)
]
# Configure mock server responses
mock_sync_server.user_manager.get_user_or_default.return_value = Mock(id="user-123")
mock_sync_server.job_manager.get_run_messages_cursor.return_value = mock_messages
mock_sync_server.job_manager.get_run_messages.return_value = mock_messages
# Test successful retrieval
response = client.get(
@ -338,9 +339,10 @@ def test_get_run_messages(client, mock_sync_server):
headers={"user_id": "user-123"},
params={
"limit": 10,
"cursor": mock_messages[0].id,
"before": "message-1234",
"after": "message-6789",
"role": "user",
"ascending": True,
"order": "desc",
},
)
assert response.status_code == 200
@ -350,12 +352,13 @@ def test_get_run_messages(client, mock_sync_server):
# Verify mock calls
mock_sync_server.user_manager.get_user_or_default.assert_called_once_with(user_id="user-123")
mock_sync_server.job_manager.get_run_messages_cursor.assert_called_once_with(
mock_sync_server.job_manager.get_run_messages.assert_called_once_with(
run_id="run-12345678",
actor=mock_sync_server.user_manager.get_user_or_default.return_value,
limit=10,
cursor=mock_messages[0].id,
ascending=True,
before="message-1234",
after="message-6789",
ascending=False,
role="user",
)
@ -365,7 +368,7 @@ def test_get_run_messages_not_found(client, mock_sync_server):
# Configure mock responses
error_message = "Run 'run-nonexistent' not found"
mock_sync_server.user_manager.get_user_or_default.return_value = Mock(id="user-123")
mock_sync_server.job_manager.get_run_messages_cursor.side_effect = NoResultFound(error_message)
mock_sync_server.job_manager.get_run_messages.side_effect = NoResultFound(error_message)
response = client.get("/v1/runs/run-nonexistent/messages", headers={"user_id": "user-123"})
@ -431,7 +434,7 @@ def test_get_tags(client, mock_sync_server):
assert response.status_code == 200
assert response.json() == ["tag1", "tag2"]
mock_sync_server.agent_manager.list_tags.assert_called_once_with(
actor=mock_sync_server.user_manager.get_user_or_default.return_value, cursor=None, limit=50, query_text=None
actor=mock_sync_server.user_manager.get_user_or_default.return_value, after=None, limit=50, query_text=None
)
@ -439,12 +442,12 @@ def test_get_tags_with_pagination(client, mock_sync_server):
"""Test tag listing with pagination parameters"""
mock_sync_server.agent_manager.list_tags.return_value = ["tag3", "tag4"]
response = client.get("/v1/tags", params={"cursor": "tag2", "limit": 2}, headers={"user_id": "test_user"})
response = client.get("/v1/tags", params={"after": "tag2", "limit": 2}, headers={"user_id": "test_user"})
assert response.status_code == 200
assert response.json() == ["tag3", "tag4"]
mock_sync_server.agent_manager.list_tags.assert_called_once_with(
actor=mock_sync_server.user_manager.get_user_or_default.return_value, cursor="tag2", limit=2, query_text=None
actor=mock_sync_server.user_manager.get_user_or_default.return_value, after="tag2", limit=2, query_text=None
)
@ -457,5 +460,134 @@ def test_get_tags_with_search(client, mock_sync_server):
assert response.status_code == 200
assert response.json() == ["user_tag1", "user_tag2"]
mock_sync_server.agent_manager.list_tags.assert_called_once_with(
actor=mock_sync_server.user_manager.get_user_or_default.return_value, cursor=None, limit=50, query_text="user"
actor=mock_sync_server.user_manager.get_user_or_default.return_value, after=None, limit=50, query_text="user"
)
# ======================================================================================================================
# Blocks Routes Tests
# ======================================================================================================================
def test_list_blocks(client, mock_sync_server):
"""
Test the GET /v1/blocks endpoint to list blocks.
"""
# Arrange: mock return from block_manager
mock_block = Block(label="human", value="Hi", is_template=True)
mock_sync_server.block_manager.get_blocks.return_value = [mock_block]
# Act
response = client.get("/v1/blocks", headers={"user_id": "test_user"})
# Assert
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["id"] == mock_block.id
mock_sync_server.block_manager.get_blocks.assert_called_once_with(
actor=mock_sync_server.user_manager.get_user_or_default.return_value,
label=None,
is_template=True,
template_name=None,
)
def test_create_block(client, mock_sync_server):
"""
Test the POST /v1/blocks endpoint to create a block.
"""
new_block = CreateBlock(label="system", value="Some system text")
returned_block = Block(**new_block.model_dump())
mock_sync_server.block_manager.create_or_update_block.return_value = returned_block
response = client.post("/v1/blocks", json=new_block.model_dump(), headers={"user_id": "test_user"})
assert response.status_code == 200
data = response.json()
assert data["id"] == returned_block.id
mock_sync_server.block_manager.create_or_update_block.assert_called_once()
def test_modify_block(client, mock_sync_server):
"""
Test the PATCH /v1/blocks/{block_id} endpoint to update a block.
"""
block_update = BlockUpdate(value="Updated text", description="New description")
updated_block = Block(label="human", value="Updated text", description="New description")
mock_sync_server.block_manager.update_block.return_value = updated_block
response = client.patch(f"/v1/blocks/{updated_block.id}", json=block_update.model_dump(), headers={"user_id": "test_user"})
assert response.status_code == 200
data = response.json()
assert data["value"] == "Updated text"
assert data["description"] == "New description"
mock_sync_server.block_manager.update_block.assert_called_once_with(
block_id=updated_block.id,
block_update=block_update,
actor=mock_sync_server.user_manager.get_user_or_default.return_value,
)
def test_delete_block(client, mock_sync_server):
"""
Test the DELETE /v1/blocks/{block_id} endpoint.
"""
deleted_block = Block(label="persona", value="Deleted text")
mock_sync_server.block_manager.delete_block.return_value = deleted_block
response = client.delete(f"/v1/blocks/{deleted_block.id}", headers={"user_id": "test_user"})
assert response.status_code == 200
data = response.json()
assert data["id"] == deleted_block.id
mock_sync_server.block_manager.delete_block.assert_called_once_with(
block_id=deleted_block.id, actor=mock_sync_server.user_manager.get_user_or_default.return_value
)
def test_retrieve_block(client, mock_sync_server):
"""
Test the GET /v1/blocks/{block_id} endpoint.
"""
existing_block = Block(label="human", value="Hello")
mock_sync_server.block_manager.get_block_by_id.return_value = existing_block
response = client.get(f"/v1/blocks/{existing_block.id}", headers={"user_id": "test_user"})
assert response.status_code == 200
data = response.json()
assert data["id"] == existing_block.id
mock_sync_server.block_manager.get_block_by_id.assert_called_once_with(
block_id=existing_block.id, actor=mock_sync_server.user_manager.get_user_or_default.return_value
)
def test_retrieve_block_404(client, mock_sync_server):
"""
Test that retrieving a non-existent block returns 404.
"""
mock_sync_server.block_manager.get_block_by_id.return_value = None
response = client.get("/v1/blocks/block-999", headers={"user_id": "test_user"})
assert response.status_code == 404
assert "Block not found" in response.json()["detail"]
def test_list_agents_for_block(client, mock_sync_server):
"""
Test the GET /v1/blocks/{block_id}/agents endpoint.
"""
mock_sync_server.block_manager.get_agents_for_block.return_value = []
response = client.get("/v1/blocks/block-abc/agents", headers={"user_id": "test_user"})
assert response.status_code == 200
data = response.json()
assert len(data) == 0
mock_sync_server.block_manager.get_agents_for_block.assert_called_once_with(
block_id="block-abc",
actor=mock_sync_server.user_manager.get_user_or_default.return_value,
)