letta-nightly 0.5.1.dev20241105104128__py3-none-any.whl → 0.5.2.dev20241107104040__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of letta-nightly might be problematic. Click here for more details.

Files changed (43) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +14 -4
  3. letta/agent_store/db.py +22 -20
  4. letta/cli/cli.py +14 -1
  5. letta/client/client.py +27 -14
  6. letta/constants.py +3 -0
  7. letta/functions/functions.py +1 -1
  8. letta/helpers/tool_rule_solver.py +1 -2
  9. letta/log.py +1 -1
  10. letta/main.py +3 -0
  11. letta/metadata.py +2 -0
  12. letta/orm/agents_tags.py +28 -0
  13. letta/orm/base.py +5 -2
  14. letta/orm/mixins.py +2 -53
  15. letta/orm/organization.py +4 -1
  16. letta/orm/sqlalchemy_base.py +22 -45
  17. letta/orm/tool.py +3 -2
  18. letta/orm/user.py +3 -1
  19. letta/schemas/agent.py +5 -0
  20. letta/schemas/agents_tags.py +33 -0
  21. letta/schemas/block.py +3 -3
  22. letta/schemas/letta_response.py +110 -0
  23. letta/schemas/llm_config.py +7 -1
  24. letta/schemas/memory.py +4 -0
  25. letta/schemas/organization.py +4 -4
  26. letta/schemas/tool.py +13 -9
  27. letta/schemas/tool_rule.py +12 -2
  28. letta/schemas/user.py +1 -1
  29. letta/server/rest_api/app.py +4 -1
  30. letta/server/rest_api/routers/v1/agents.py +7 -122
  31. letta/server/rest_api/routers/v1/organizations.py +2 -1
  32. letta/server/rest_api/routers/v1/tools.py +3 -2
  33. letta/server/rest_api/routers/v1/users.py +14 -2
  34. letta/server/server.py +75 -44
  35. letta/services/agents_tags_manager.py +64 -0
  36. letta/services/organization_manager.py +4 -4
  37. letta/services/tool_manager.py +22 -30
  38. letta/services/user_manager.py +3 -3
  39. {letta_nightly-0.5.1.dev20241105104128.dist-info → letta_nightly-0.5.2.dev20241107104040.dist-info}/METADATA +5 -2
  40. {letta_nightly-0.5.1.dev20241105104128.dist-info → letta_nightly-0.5.2.dev20241107104040.dist-info}/RECORD +43 -40
  41. {letta_nightly-0.5.1.dev20241105104128.dist-info → letta_nightly-0.5.2.dev20241107104040.dist-info}/LICENSE +0 -0
  42. {letta_nightly-0.5.1.dev20241105104128.dist-info → letta_nightly-0.5.2.dev20241107104040.dist-info}/WHEEL +0 -0
  43. {letta_nightly-0.5.1.dev20241105104128.dist-info → letta_nightly-0.5.2.dev20241107104040.dist-info}/entry_points.txt +0 -0
letta/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "0.5.1"
1
+ __version__ = "0.5.2"
2
2
 
3
3
  # import clients
4
4
  from letta.client.client import LocalClient, RESTClient, create_client
letta/agent.py CHANGED
@@ -248,9 +248,21 @@ class Agent(BaseAgent):
248
248
  # initialize a tool rules solver
249
249
  if agent_state.tool_rules:
250
250
  # if there are tool rules, print out a warning
251
- warnings.warn("Tool rules only work reliably for the latest OpenAI models that support structured outputs.")
251
+ for rule in agent_state.tool_rules:
252
+ if not isinstance(rule, TerminalToolRule):
253
+ warnings.warn("Tool rules only work reliably for the latest OpenAI models that support structured outputs.")
254
+ break
252
255
  # add default rule for having send_message be a terminal tool
253
- agent_state.tool_rules.append(TerminalToolRule(tool_name="send_message"))
256
+ if agent_state.tool_rules is None:
257
+ agent_state.tool_rules = []
258
+ # Define the rule to add
259
+ send_message_terminal_rule = TerminalToolRule(tool_name="send_message")
260
+ # Check if an equivalent rule is already present
261
+ if not any(
262
+ isinstance(rule, TerminalToolRule) and rule.tool_name == send_message_terminal_rule.tool_name for rule in agent_state.tool_rules
263
+ ):
264
+ agent_state.tool_rules.append(send_message_terminal_rule)
265
+
254
266
  self.tool_rules_solver = ToolRulesSolver(tool_rules=agent_state.tool_rules)
255
267
 
256
268
  # gpt-4, gpt-3.5-turbo, ...
@@ -392,7 +404,6 @@ class Agent(BaseAgent):
392
404
  exec(tool.module, env)
393
405
  else:
394
406
  exec(tool.source_code, env)
395
-
396
407
  self.functions_python[tool.json_schema["name"]] = env[tool.json_schema["name"]]
397
408
  self.functions.append(tool.json_schema)
398
409
  except Exception as e:
@@ -784,7 +795,6 @@ class Agent(BaseAgent):
784
795
 
785
796
  # Update ToolRulesSolver state with last called function
786
797
  self.tool_rules_solver.update_tool_usage(function_name)
787
-
788
798
  # Update heartbeat request according to provided tool rules
789
799
  if self.tool_rules_solver.has_children_tools(function_name):
790
800
  heartbeat_request = True
letta/agent_store/db.py CHANGED
@@ -358,26 +358,26 @@ class PostgresStorageConnector(SQLStorageConnector):
358
358
  # construct URI from enviornment variables
359
359
  if settings.pg_uri:
360
360
  self.uri = settings.pg_uri
361
+
362
+ # use config URI
363
+ # TODO: remove this eventually (config should NOT contain URI)
364
+ if table_type == TableType.ARCHIVAL_MEMORY or table_type == TableType.PASSAGES:
365
+ self.uri = self.config.archival_storage_uri
366
+ self.db_model = PassageModel
367
+ if self.config.archival_storage_uri is None:
368
+ raise ValueError(f"Must specify archival_storage_uri in config {self.config.config_path}")
369
+ elif table_type == TableType.RECALL_MEMORY:
370
+ self.uri = self.config.recall_storage_uri
371
+ self.db_model = MessageModel
372
+ if self.config.recall_storage_uri is None:
373
+ raise ValueError(f"Must specify recall_storage_uri in config {self.config.config_path}")
374
+ elif table_type == TableType.FILES:
375
+ self.uri = self.config.metadata_storage_uri
376
+ self.db_model = FileMetadataModel
377
+ if self.config.metadata_storage_uri is None:
378
+ raise ValueError(f"Must specify metadata_storage_uri in config {self.config.config_path}")
361
379
  else:
362
- # use config URI
363
- # TODO: remove this eventually (config should NOT contain URI)
364
- if table_type == TableType.ARCHIVAL_MEMORY or table_type == TableType.PASSAGES:
365
- self.uri = self.config.archival_storage_uri
366
- self.db_model = PassageModel
367
- if self.config.archival_storage_uri is None:
368
- raise ValueError(f"Must specify archival_storage_uri in config {self.config.config_path}")
369
- elif table_type == TableType.RECALL_MEMORY:
370
- self.uri = self.config.recall_storage_uri
371
- self.db_model = MessageModel
372
- if self.config.recall_storage_uri is None:
373
- raise ValueError(f"Must specify recall_storage_uri in config {self.config.config_path}")
374
- elif table_type == TableType.FILES:
375
- self.uri = self.config.metadata_storage_uri
376
- self.db_model = FileMetadataModel
377
- if self.config.metadata_storage_uri is None:
378
- raise ValueError(f"Must specify metadata_storage_uri in config {self.config.config_path}")
379
- else:
380
- raise ValueError(f"Table type {table_type} not implemented")
380
+ raise ValueError(f"Table type {table_type} not implemented")
381
381
 
382
382
  for c in self.db_model.__table__.columns:
383
383
  if c.name == "embedding":
@@ -578,4 +578,6 @@ class SQLLiteStorageConnector(SQLStorageConnector):
578
578
  def attach_base():
579
579
  # This should be invoked in server.py to make sure Base gets initialized properly
580
580
  # DO NOT REMOVE
581
- print("Initializing database...")
581
+ from letta.utils import printd
582
+
583
+ printd("Initializing database...")
letta/cli/cli.py CHANGED
@@ -10,7 +10,7 @@ import letta.utils as utils
10
10
  from letta import create_client
11
11
  from letta.agent import Agent, save_agent
12
12
  from letta.config import LettaConfig
13
- from letta.constants import CLI_WARNING_PREFIX, LETTA_DIR
13
+ from letta.constants import CLI_WARNING_PREFIX, LETTA_DIR, MIN_CONTEXT_WINDOW
14
14
  from letta.local_llm.constants import ASSISTANT_MESSAGE_CLI_SYMBOL
15
15
  from letta.log import get_logger
16
16
  from letta.metadata import MetadataStore
@@ -244,6 +244,19 @@ def run(
244
244
  llm_model_name = questionary.select("Select LLM model:", choices=llm_choices).ask().model
245
245
  llm_config = [llm_config for llm_config in llm_configs if llm_config.model == llm_model_name][0]
246
246
 
247
+ # option to override context window
248
+ if llm_config.context_window is not None:
249
+ context_window_validator = lambda x: x.isdigit() and int(x) > MIN_CONTEXT_WINDOW and int(x) <= llm_config.context_window
250
+ context_window_input = questionary.text(
251
+ "Select LLM context window limit (hit enter for default):",
252
+ default=str(llm_config.context_window),
253
+ validate=context_window_validator,
254
+ ).ask()
255
+ if context_window_input is not None:
256
+ llm_config.context_window = int(context_window_input)
257
+ else:
258
+ sys.exit(1)
259
+
247
260
  # choose form list of embedding configs
248
261
  embedding_configs = client.list_embedding_configs()
249
262
  embedding_options = [embedding_config.embedding_model for embedding_config in embedding_configs]
letta/client/client.py CHANGED
@@ -77,6 +77,7 @@ class AbstractClient(object):
77
77
  memory: Memory = ChatMemory(human=get_human_text(DEFAULT_HUMAN), persona=get_persona_text(DEFAULT_PERSONA)),
78
78
  system: Optional[str] = None,
79
79
  tools: Optional[List[str]] = None,
80
+ tool_rules: Optional[List[BaseToolRule]] = None,
80
81
  include_base_tools: Optional[bool] = True,
81
82
  metadata: Optional[Dict] = {"human:": DEFAULT_HUMAN, "persona": DEFAULT_PERSONA},
82
83
  description: Optional[str] = None,
@@ -333,8 +334,12 @@ class RESTClient(AbstractClient):
333
334
  self._default_llm_config = default_llm_config
334
335
  self._default_embedding_config = default_embedding_config
335
336
 
336
- def list_agents(self) -> List[AgentState]:
337
- response = requests.get(f"{self.base_url}/{self.api_prefix}/agents", headers=self.headers)
337
+ def list_agents(self, tags: Optional[List[str]] = None) -> List[AgentState]:
338
+ params = {}
339
+ if tags:
340
+ params["tags"] = tags
341
+
342
+ response = requests.get(f"{self.base_url}/{self.api_prefix}/agents", headers=self.headers, params=params)
338
343
  return [AgentState(**agent) for agent in response.json()]
339
344
 
340
345
  def agent_exists(self, agent_id: str) -> bool:
@@ -372,6 +377,7 @@ class RESTClient(AbstractClient):
372
377
  system: Optional[str] = None,
373
378
  # tools
374
379
  tools: Optional[List[str]] = None,
380
+ tool_rules: Optional[List[BaseToolRule]] = None,
375
381
  include_base_tools: Optional[bool] = True,
376
382
  # metadata
377
383
  metadata: Optional[Dict] = {"human:": DEFAULT_HUMAN, "persona": DEFAULT_PERSONA},
@@ -425,6 +431,7 @@ class RESTClient(AbstractClient):
425
431
  metadata_=metadata,
426
432
  memory=memory,
427
433
  tools=tool_names,
434
+ tool_rules=tool_rules,
428
435
  system=system,
429
436
  agent_type=agent_type,
430
437
  llm_config=llm_config if llm_config else self._default_llm_config,
@@ -477,6 +484,7 @@ class RESTClient(AbstractClient):
477
484
  description: Optional[str] = None,
478
485
  system: Optional[str] = None,
479
486
  tools: Optional[List[str]] = None,
487
+ tags: Optional[List[str]] = None,
480
488
  metadata: Optional[Dict] = None,
481
489
  llm_config: Optional[LLMConfig] = None,
482
490
  embedding_config: Optional[EmbeddingConfig] = None,
@@ -506,6 +514,7 @@ class RESTClient(AbstractClient):
506
514
  name=name,
507
515
  system=system,
508
516
  tools=tools,
517
+ tags=tags,
509
518
  description=description,
510
519
  metadata_=metadata,
511
520
  llm_config=llm_config,
@@ -612,7 +621,12 @@ class RESTClient(AbstractClient):
612
621
  agent_id (str): ID of the agent
613
622
  """
614
623
  # TODO: implement this
615
- raise NotImplementedError
624
+ response = requests.get(f"{self.base_url}/{self.api_prefix}/agents", headers=self.headers, params={"name": agent_name})
625
+ agents = [AgentState(**agent) for agent in response.json()]
626
+ if len(agents) == 0:
627
+ return None
628
+ assert len(agents) == 1, f"Multiple agents with the same name: {agents}"
629
+ return agents[0].id
616
630
 
617
631
  # memory
618
632
  def get_in_context_memory(self, agent_id: str) -> Memory:
@@ -1609,13 +1623,10 @@ class LocalClient(AbstractClient):
1609
1623
  self.organization = self.server.get_organization_or_default(self.org_id)
1610
1624
 
1611
1625
  # agents
1612
- def list_agents(self) -> List[AgentState]:
1626
+ def list_agents(self, tags: Optional[List[str]] = None) -> List[AgentState]:
1613
1627
  self.interface.clear()
1614
1628
 
1615
- # TODO: fix the server function
1616
- # return self.server.list_agents(user_id=self.user_id)
1617
-
1618
- return self.server.ms.list_agents(user_id=self.user_id)
1629
+ return self.server.list_agents(user_id=self.user_id, tags=tags)
1619
1630
 
1620
1631
  def agent_exists(self, agent_id: Optional[str] = None, agent_name: Optional[str] = None) -> bool:
1621
1632
  """
@@ -1749,6 +1760,7 @@ class LocalClient(AbstractClient):
1749
1760
  description: Optional[str] = None,
1750
1761
  system: Optional[str] = None,
1751
1762
  tools: Optional[List[str]] = None,
1763
+ tags: Optional[List[str]] = None,
1752
1764
  metadata: Optional[Dict] = None,
1753
1765
  llm_config: Optional[LLMConfig] = None,
1754
1766
  embedding_config: Optional[EmbeddingConfig] = None,
@@ -1780,6 +1792,7 @@ class LocalClient(AbstractClient):
1780
1792
  name=name,
1781
1793
  system=system,
1782
1794
  tools=tools,
1795
+ tags=tags,
1783
1796
  description=description,
1784
1797
  metadata_=metadata,
1785
1798
  llm_config=llm_config,
@@ -1864,7 +1877,7 @@ class LocalClient(AbstractClient):
1864
1877
  agent_state (AgentState): State of the agent
1865
1878
  """
1866
1879
  self.interface.clear()
1867
- return self.server.get_agent(agent_name=agent_name, user_id=self.user_id, agent_id=None)
1880
+ return self.server.get_agent_state(agent_name=agent_name, user_id=self.user_id, agent_id=None)
1868
1881
 
1869
1882
  def get_agent(self, agent_id: str) -> AgentState:
1870
1883
  """
@@ -2267,18 +2280,18 @@ class LocalClient(AbstractClient):
2267
2280
  langchain_tool=langchain_tool,
2268
2281
  additional_imports_module_attr_map=additional_imports_module_attr_map,
2269
2282
  )
2270
- return self.server.tool_manager.create_or_update_tool(tool_create, actor=self.user)
2283
+ return self.server.tool_manager.create_or_update_tool(pydantic_tool=Tool(**tool_create.model_dump()), actor=self.user)
2271
2284
 
2272
2285
  def load_crewai_tool(self, crewai_tool: "CrewAIBaseTool", additional_imports_module_attr_map: dict[str, str] = None) -> Tool:
2273
2286
  tool_create = ToolCreate.from_crewai(
2274
2287
  crewai_tool=crewai_tool,
2275
2288
  additional_imports_module_attr_map=additional_imports_module_attr_map,
2276
2289
  )
2277
- return self.server.tool_manager.create_or_update_tool(tool_create, actor=self.user)
2290
+ return self.server.tool_manager.create_or_update_tool(pydantic_tool=Tool(**tool_create.model_dump()), actor=self.user)
2278
2291
 
2279
2292
  def load_composio_tool(self, action: "ActionType") -> Tool:
2280
2293
  tool_create = ToolCreate.from_composio(action=action)
2281
- return self.server.tool_manager.create_or_update_tool(tool_create, actor=self.user)
2294
+ return self.server.tool_manager.create_or_update_tool(pydantic_tool=Tool(**tool_create.model_dump()), actor=self.user)
2282
2295
 
2283
2296
  # TODO: Use the above function `add_tool` here as there is duplicate logic
2284
2297
  def create_tool(
@@ -2310,7 +2323,7 @@ class LocalClient(AbstractClient):
2310
2323
 
2311
2324
  # call server function
2312
2325
  return self.server.tool_manager.create_or_update_tool(
2313
- ToolCreate(
2326
+ Tool(
2314
2327
  source_type=source_type,
2315
2328
  source_code=source_code,
2316
2329
  name=name,
@@ -2738,7 +2751,7 @@ class LocalClient(AbstractClient):
2738
2751
  return self.server.list_embedding_models()
2739
2752
 
2740
2753
  def create_org(self, name: Optional[str] = None) -> Organization:
2741
- return self.server.organization_manager.create_organization(name=name)
2754
+ return self.server.organization_manager.create_organization(pydantic_org=Organization(name=name))
2742
2755
 
2743
2756
  def list_orgs(self, cursor: Optional[str] = None, limit: Optional[int] = 50) -> List[Organization]:
2744
2757
  return self.server.organization_manager.list_organizations(cursor=cursor, limit=limit)
letta/constants.py CHANGED
@@ -18,6 +18,9 @@ IN_CONTEXT_MEMORY_KEYWORD = "CORE_MEMORY"
18
18
  # OpenAI error message: Invalid 'messages[1].tool_calls[0].id': string too long. Expected a string with maximum length 29, but got a string with length 36 instead.
19
19
  TOOL_CALL_ID_MAX_LEN = 29
20
20
 
21
+ # minimum context window size
22
+ MIN_CONTEXT_WINDOW = 4000
23
+
21
24
  # embeddings
22
25
  MAX_EMBEDDING_DIM = 4096 # maximum supported embeding size - do NOT change or else DBs will need to be reset
23
26
 
@@ -9,7 +9,7 @@ from letta.constants import CLI_WARNING_PREFIX
9
9
  from letta.functions.schema_generator import generate_schema
10
10
 
11
11
 
12
- def derive_openai_json_schema(source_code: str, name: Optional[str]) -> dict:
12
+ def derive_openai_json_schema(source_code: str, name: Optional[str] = None) -> dict:
13
13
  # auto-generate openai schema
14
14
  try:
15
15
  # Define a custom environment with necessary imports
@@ -1,4 +1,3 @@
1
- import warnings
2
1
  from typing import Dict, List, Optional, Set
3
2
 
4
3
  from pydantic import BaseModel, Field
@@ -67,7 +66,7 @@ class ToolRulesSolver(BaseModel):
67
66
  if error_on_empty:
68
67
  raise RuntimeError(message)
69
68
  else:
70
- warnings.warn(message)
69
+ # warnings.warn(message)
71
70
  return []
72
71
 
73
72
  def is_terminal_tool(self, tool_name: str) -> bool:
letta/log.py CHANGED
@@ -56,7 +56,7 @@ DEVELOPMENT_LOGGING = {
56
56
  "propagate": False,
57
57
  },
58
58
  "uvicorn": {
59
- "level": "INFO",
59
+ "level": "CRITICAL",
60
60
  "handlers": ["console"],
61
61
  "propagate": False,
62
62
  },
letta/main.py CHANGED
@@ -26,6 +26,9 @@ from letta.streaming_interface import AgentRefreshStreamingInterface
26
26
 
27
27
  # interface = interface()
28
28
 
29
+ # disable composio print on exit
30
+ os.environ["COMPOSIO_DISABLE_VERSION_CHECK"] = "true"
31
+
29
32
  app = typer.Typer(pretty_exceptions_enable=False)
30
33
  app.command(name="run")(run)
31
34
  app.command(name="version")(version)
letta/metadata.py CHANGED
@@ -493,6 +493,7 @@ class MetadataStore:
493
493
  fields = vars(agent)
494
494
  fields["memory"] = agent.memory.to_dict()
495
495
  del fields["_internal_memory"]
496
+ del fields["tags"]
496
497
  session.add(AgentModel(**fields))
497
498
  session.commit()
498
499
 
@@ -531,6 +532,7 @@ class MetadataStore:
531
532
  if isinstance(agent.memory, Memory): # TODO: this is nasty but this whole class will soon be removed so whatever
532
533
  fields["memory"] = agent.memory.to_dict()
533
534
  del fields["_internal_memory"]
535
+ del fields["tags"]
534
536
  session.query(AgentModel).filter(AgentModel.id == agent.id).update(fields)
535
537
  session.commit()
536
538
 
@@ -0,0 +1,28 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from sqlalchemy import ForeignKey, String, UniqueConstraint
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from letta.orm.mixins import OrganizationMixin
7
+ from letta.orm.sqlalchemy_base import SqlalchemyBase
8
+ from letta.schemas.agents_tags import AgentsTags as PydanticAgentsTags
9
+
10
+ if TYPE_CHECKING:
11
+ from letta.orm.organization import Organization
12
+
13
+
14
+ class AgentsTags(SqlalchemyBase, OrganizationMixin):
15
+ """Associates tags with agents, allowing agents to have multiple tags and supporting tag-based filtering."""
16
+
17
+ __tablename__ = "agents_tags"
18
+ __pydantic_model__ = PydanticAgentsTags
19
+ __table_args__ = (UniqueConstraint("agent_id", "tag", name="unique_agent_tag"),)
20
+
21
+ # The agent associated with this tag
22
+ agent_id = mapped_column(String, ForeignKey("agents.id"), primary_key=True)
23
+
24
+ # The name of the tag
25
+ tag: Mapped[str] = mapped_column(String, nullable=False, doc="The name of the tag associated with the agent.")
26
+
27
+ # relationships
28
+ organization: Mapped["Organization"] = relationship("Organization", back_populates="agents_tags")
letta/orm/base.py CHANGED
@@ -67,7 +67,7 @@ class CommonSqlalchemyMetaMixins(Base):
67
67
  prop_value = getattr(self, full_prop, None)
68
68
  if not prop_value:
69
69
  return
70
- return f"user-{prop_value}"
70
+ return prop_value
71
71
 
72
72
  def _user_id_setter(self, prop: str, value: str) -> None:
73
73
  """returns the user id for the specified property"""
@@ -75,6 +75,9 @@ class CommonSqlalchemyMetaMixins(Base):
75
75
  if not value:
76
76
  setattr(self, full_prop, None)
77
77
  return
78
+ # Safety check
78
79
  prefix, id_ = value.split("-", 1)
79
80
  assert prefix == "user", f"{prefix} is not a valid id prefix for a user id"
80
- setattr(self, full_prop, id_)
81
+
82
+ # Set the full value
83
+ setattr(self, full_prop, value)
letta/orm/mixins.py CHANGED
@@ -1,11 +1,9 @@
1
- from typing import Optional
2
1
  from uuid import UUID
3
2
 
4
3
  from sqlalchemy import ForeignKey, String
5
4
  from sqlalchemy.orm import Mapped, mapped_column
6
5
 
7
6
  from letta.orm.base import Base
8
- from letta.orm.errors import MalformedIdError
9
7
 
10
8
 
11
9
  def is_valid_uuid4(uuid_string: str) -> bool:
@@ -17,53 +15,12 @@ def is_valid_uuid4(uuid_string: str) -> bool:
17
15
  return False
18
16
 
19
17
 
20
- def _relation_getter(instance: "Base", prop: str) -> Optional[str]:
21
- """Get relation and return id with prefix as a string."""
22
- prefix = prop.replace("_", "")
23
- formatted_prop = f"_{prop}_id"
24
- try:
25
- id_ = getattr(instance, formatted_prop) # Get the string id directly
26
- return f"{prefix}-{id_}"
27
- except AttributeError:
28
- return None
29
-
30
-
31
- def _relation_setter(instance: "Base", prop: str, value: str) -> None:
32
- """Set relation using the id with prefix, ensuring the id is a valid UUIDv4."""
33
- formatted_prop = f"_{prop}_id"
34
- prefix = prop.replace("_", "")
35
- if not value:
36
- setattr(instance, formatted_prop, None)
37
- return
38
- try:
39
- found_prefix, id_ = value.split("-", 1)
40
- except ValueError as e:
41
- raise MalformedIdError(f"{value} is not a valid ID.") from e
42
-
43
- # Ensure prefix matches
44
- assert found_prefix == prefix, f"{found_prefix} is not a valid id prefix, expecting {prefix}"
45
-
46
- # Validate that the id is a valid UUID4 string
47
- if not is_valid_uuid4(id_):
48
- raise MalformedIdError(f"Hash segment of {value} is not a valid UUID4")
49
-
50
- setattr(instance, formatted_prop, id_) # Store id as a string
51
-
52
-
53
18
  class OrganizationMixin(Base):
54
19
  """Mixin for models that belong to an organization."""
55
20
 
56
21
  __abstract__ = True
57
22
 
58
- _organization_id: Mapped[str] = mapped_column(String, ForeignKey("organization._id"))
59
-
60
- @property
61
- def organization_id(self) -> str:
62
- return _relation_getter(self, "organization")
63
-
64
- @organization_id.setter
65
- def organization_id(self, value: str) -> None:
66
- _relation_setter(self, "organization", value)
23
+ organization_id: Mapped[str] = mapped_column(String, ForeignKey("organizations.id"))
67
24
 
68
25
 
69
26
  class UserMixin(Base):
@@ -71,12 +28,4 @@ class UserMixin(Base):
71
28
 
72
29
  __abstract__ = True
73
30
 
74
- _user_id: Mapped[str] = mapped_column(String, ForeignKey("user._id"))
75
-
76
- @property
77
- def user_id(self) -> str:
78
- return _relation_getter(self, "user")
79
-
80
- @user_id.setter
81
- def user_id(self, value: str) -> None:
82
- _relation_setter(self, "user", value)
31
+ user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"))
letta/orm/organization.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from typing import TYPE_CHECKING, List
2
2
 
3
+ from sqlalchemy import String
3
4
  from sqlalchemy.orm import Mapped, mapped_column, relationship
4
5
 
5
6
  from letta.orm.sqlalchemy_base import SqlalchemyBase
@@ -14,13 +15,15 @@ if TYPE_CHECKING:
14
15
  class Organization(SqlalchemyBase):
15
16
  """The highest level of the object tree. All Entities belong to one and only one Organization."""
16
17
 
17
- __tablename__ = "organization"
18
+ __tablename__ = "organizations"
18
19
  __pydantic_model__ = PydanticOrganization
19
20
 
21
+ id: Mapped[str] = mapped_column(String, primary_key=True)
20
22
  name: Mapped[str] = mapped_column(doc="The display name of the organization.")
21
23
 
22
24
  users: Mapped[List["User"]] = relationship("User", back_populates="organization", cascade="all, delete-orphan")
23
25
  tools: Mapped[List["Tool"]] = relationship("Tool", back_populates="organization", cascade="all, delete-orphan")
26
+ agents_tags: Mapped[List["AgentsTags"]] = relationship("AgentsTags", back_populates="organization", cascade="all, delete-orphan")
24
27
 
25
28
  # TODO: Map these relationships later when we actually make these models
26
29
  # below is just a suggestion
@@ -1,14 +1,11 @@
1
1
  from typing import TYPE_CHECKING, List, Literal, Optional, Type
2
- from uuid import uuid4
3
2
 
4
- from humps import depascalize
5
- from sqlalchemy import Boolean, String, select
3
+ from sqlalchemy import String, select
6
4
  from sqlalchemy.orm import Mapped, mapped_column
7
5
 
8
6
  from letta.log import get_logger
9
7
  from letta.orm.base import Base, CommonSqlalchemyMetaMixins
10
8
  from letta.orm.errors import NoResultFound
11
- from letta.orm.mixins import is_valid_uuid4
12
9
 
13
10
  if TYPE_CHECKING:
14
11
  from pydantic import BaseModel
@@ -24,27 +21,7 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
24
21
 
25
22
  __order_by_default__ = "created_at"
26
23
 
27
- _id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"{uuid4()}")
28
-
29
- deleted: Mapped[bool] = mapped_column(Boolean, default=False, doc="Is this record deleted? Used for universal soft deletes.")
30
-
31
- @classmethod
32
- def __prefix__(cls) -> str:
33
- return depascalize(cls.__name__)
34
-
35
- @property
36
- def id(self) -> Optional[str]:
37
- if self._id:
38
- return f"{self.__prefix__()}-{self._id}"
39
-
40
- @id.setter
41
- def id(self, value: str) -> None:
42
- if not value:
43
- return
44
- prefix, id_ = value.split("-", 1)
45
- assert prefix == self.__prefix__(), f"{prefix} is not a valid id prefix for {self.__class__.__name__}"
46
- assert is_valid_uuid4(id_), f"{id_} is not a valid uuid4"
47
- self._id = id_
24
+ id: Mapped[str] = mapped_column(String, primary_key=True)
48
25
 
49
26
  @classmethod
50
27
  def list(
@@ -57,11 +34,10 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
57
34
 
58
35
  # Add a cursor condition if provided
59
36
  if cursor:
60
- cursor_uuid = cls.get_uid_from_identifier(cursor) # Assuming the cursor is an _id value
61
- query = query.where(cls._id > cursor_uuid)
37
+ query = query.where(cls.id > cursor)
62
38
 
63
39
  # Add a limit to the query if provided
64
- query = query.order_by(cls._id).limit(limit)
40
+ query = query.order_by(cls.id).limit(limit)
65
41
 
66
42
  # Handle soft deletes if the class has the 'is_deleted' attribute
67
43
  if hasattr(cls, "is_deleted"):
@@ -70,20 +46,6 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
70
46
  # Execute the query and return the results as a list of model instances
71
47
  return list(session.execute(query).scalars())
72
48
 
73
- @classmethod
74
- def get_uid_from_identifier(cls, identifier: str, indifferent: Optional[bool] = False) -> str:
75
- """converts the id into a uuid object
76
- Args:
77
- identifier: the string identifier, such as `organization-xxxx-xx...`
78
- indifferent: if True, will not enforce the prefix check
79
- """
80
- try:
81
- uuid_string = identifier.split("-", 1)[1] if indifferent else identifier.replace(f"{cls.__prefix__()}-", "")
82
- assert is_valid_uuid4(uuid_string)
83
- return uuid_string
84
- except ValueError as e:
85
- raise ValueError(f"{identifier} is not a valid identifier for class {cls.__name__}") from e
86
-
87
49
  @classmethod
88
50
  def read(
89
51
  cls,
@@ -112,8 +74,7 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
112
74
 
113
75
  # If an identifier is provided, add it to the query conditions
114
76
  if identifier is not None:
115
- identifier = cls.get_uid_from_identifier(identifier)
116
- query = query.where(cls._id == identifier)
77
+ query = query.where(cls.id == identifier)
117
78
  query_conditions.append(f"id='{identifier}'")
118
79
 
119
80
  if kwargs:
@@ -151,6 +112,22 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
151
112
  self.is_deleted = True
152
113
  return self.update(db_session)
153
114
 
115
+ def hard_delete(self, db_session: "Session", actor: Optional["User"] = None) -> None:
116
+ """Permanently removes the record from the database."""
117
+ if actor:
118
+ logger.info(f"User {actor.id} requested hard deletion of {self.__class__.__name__} with ID {self.id}")
119
+
120
+ with db_session as session:
121
+ try:
122
+ session.delete(self)
123
+ session.commit()
124
+ except Exception as e:
125
+ session.rollback()
126
+ logger.exception(f"Failed to hard delete {self.__class__.__name__} with ID {self.id}")
127
+ raise ValueError(f"Failed to hard delete {self.__class__.__name__} with ID {self.id}: {e}")
128
+ else:
129
+ logger.info(f"{self.__class__.__name__} with ID {self.id} successfully hard deleted")
130
+
154
131
  def update(self, db_session: "Session", actor: Optional["User"] = None) -> Type["SqlalchemyBase"]:
155
132
  if actor:
156
133
  self._set_created_and_updated_by_fields(actor.id)
@@ -183,7 +160,7 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
183
160
  org_id = getattr(actor, "organization_id", None)
184
161
  if not org_id:
185
162
  raise ValueError(f"object {actor} has no organization accessor")
186
- return query.where(cls._organization_id == cls.get_uid_from_identifier(org_id, indifferent=True), cls.is_deleted == False)
163
+ return query.where(cls.organization_id == org_id, cls.is_deleted == False)
187
164
 
188
165
  @property
189
166
  def __pydantic_model__(self) -> Type["BaseModel"]:
letta/orm/tool.py CHANGED
@@ -21,13 +21,14 @@ class Tool(SqlalchemyBase, OrganizationMixin):
21
21
  more granular permissions.
22
22
  """
23
23
 
24
- __tablename__ = "tool"
24
+ __tablename__ = "tools"
25
25
  __pydantic_model__ = PydanticTool
26
26
 
27
27
  # Add unique constraint on (name, _organization_id)
28
28
  # An organization should not have multiple tools with the same name
29
- __table_args__ = (UniqueConstraint("name", "_organization_id", name="uix_name_organization"),)
29
+ __table_args__ = (UniqueConstraint("name", "organization_id", name="uix_name_organization"),)
30
30
 
31
+ id: Mapped[str] = mapped_column(String, primary_key=True)
31
32
  name: Mapped[str] = mapped_column(doc="The display name of the tool.")
32
33
  description: Mapped[Optional[str]] = mapped_column(nullable=True, doc="The description of the tool.")
33
34
  tags: Mapped[List] = mapped_column(JSON, doc="Metadata tags used to filter tools.")
letta/orm/user.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from typing import TYPE_CHECKING
2
2
 
3
+ from sqlalchemy import String
3
4
  from sqlalchemy.orm import Mapped, mapped_column, relationship
4
5
 
5
6
  from letta.orm.mixins import OrganizationMixin
@@ -13,9 +14,10 @@ if TYPE_CHECKING:
13
14
  class User(SqlalchemyBase, OrganizationMixin):
14
15
  """User ORM class"""
15
16
 
16
- __tablename__ = "user"
17
+ __tablename__ = "users"
17
18
  __pydantic_model__ = PydanticUser
18
19
 
20
+ id: Mapped[str] = mapped_column(String, primary_key=True)
19
21
  name: Mapped[str] = mapped_column(nullable=False, doc="The display name of the user.")
20
22
 
21
23
  # relationships