letta-nightly 0.6.50.dev20250411104155__py3-none-any.whl → 0.6.52.dev20250412051016__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.
Files changed (51) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +23 -32
  3. letta/agents/base_agent.py +17 -6
  4. letta/agents/ephemeral_agent.py +5 -6
  5. letta/agents/ephemeral_memory_agent.py +8 -10
  6. letta/agents/helpers.py +6 -6
  7. letta/agents/letta_agent.py +9 -10
  8. letta/agents/letta_agent_batch.py +164 -0
  9. letta/agents/voice_agent.py +8 -8
  10. letta/functions/function_sets/base.py +1 -1
  11. letta/helpers/converters.py +5 -2
  12. letta/helpers/tool_rule_solver.py +12 -2
  13. letta/jobs/scheduler.py +13 -11
  14. letta/llm_api/anthropic.py +0 -1
  15. letta/llm_api/anthropic_client.py +61 -23
  16. letta/llm_api/cohere.py +1 -1
  17. letta/llm_api/google_ai_client.py +48 -13
  18. letta/llm_api/google_vertex_client.py +19 -1
  19. letta/llm_api/llm_client_base.py +13 -5
  20. letta/llm_api/openai.py +4 -3
  21. letta/llm_api/openai_client.py +18 -10
  22. letta/orm/organization.py +4 -2
  23. letta/orm/sqlalchemy_base.py +3 -0
  24. letta/schemas/enums.py +1 -0
  25. letta/schemas/group.py +30 -1
  26. letta/schemas/identity.py +10 -0
  27. letta/schemas/letta_request.py +4 -0
  28. letta/schemas/letta_response.py +9 -1
  29. letta/schemas/llm_config.py +10 -0
  30. letta/schemas/message.py +21 -12
  31. letta/schemas/openai/chat_completion_request.py +1 -0
  32. letta/schemas/tool_rule.py +14 -1
  33. letta/server/rest_api/interface.py +5 -4
  34. letta/server/rest_api/routers/v1/agents.py +20 -13
  35. letta/server/rest_api/routers/v1/groups.py +1 -1
  36. letta/server/rest_api/routers/v1/identities.py +23 -2
  37. letta/server/rest_api/utils.py +20 -22
  38. letta/server/server.py +34 -21
  39. letta/services/agent_manager.py +13 -9
  40. letta/services/block_manager.py +2 -4
  41. letta/services/identity_manager.py +21 -5
  42. letta/services/llm_batch_manager.py +21 -1
  43. letta/services/summarizer/summarizer.py +11 -4
  44. letta/services/tool_manager.py +1 -1
  45. letta/settings.py +1 -0
  46. letta/utils.py +2 -2
  47. {letta_nightly-0.6.50.dev20250411104155.dist-info → letta_nightly-0.6.52.dev20250412051016.dist-info}/METADATA +3 -3
  48. {letta_nightly-0.6.50.dev20250411104155.dist-info → letta_nightly-0.6.52.dev20250412051016.dist-info}/RECORD +51 -50
  49. {letta_nightly-0.6.50.dev20250411104155.dist-info → letta_nightly-0.6.52.dev20250412051016.dist-info}/LICENSE +0 -0
  50. {letta_nightly-0.6.50.dev20250411104155.dist-info → letta_nightly-0.6.52.dev20250412051016.dist-info}/WHEEL +0 -0
  51. {letta_nightly-0.6.50.dev20250411104155.dist-info → letta_nightly-0.6.52.dev20250412051016.dist-info}/entry_points.txt +0 -0
@@ -21,6 +21,7 @@ from letta.llm_api.helpers import add_inner_thoughts_to_functions, convert_to_st
21
21
  from letta.llm_api.llm_client_base import LLMClientBase
22
22
  from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION, INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST
23
23
  from letta.log import get_logger
24
+ from letta.schemas.llm_config import LLMConfig
24
25
  from letta.schemas.message import Message as PydanticMessage
25
26
  from letta.schemas.openai.chat_completion_request import ChatCompletionRequest
26
27
  from letta.schemas.openai.chat_completion_request import FunctionCall as ToolFunctionChoiceFunctionCall
@@ -45,17 +46,18 @@ class OpenAIClient(LLMClientBase):
45
46
  def build_request_data(
46
47
  self,
47
48
  messages: List[PydanticMessage],
49
+ llm_config: LLMConfig,
48
50
  tools: Optional[List[dict]] = None, # Keep as dict for now as per base class
49
51
  force_tool_call: Optional[str] = None,
50
52
  ) -> dict:
51
53
  """
52
54
  Constructs a request object in the expected data format for the OpenAI API.
53
55
  """
54
- if tools and self.llm_config.put_inner_thoughts_in_kwargs:
56
+ if tools and llm_config.put_inner_thoughts_in_kwargs:
55
57
  # Special case for LM Studio backend since it needs extra guidance to force out the thoughts first
56
58
  # TODO(fix)
57
59
  inner_thoughts_desc = (
58
- INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST if ":1234" in self.llm_config.model_endpoint else INNER_THOUGHTS_KWARG_DESCRIPTION
60
+ INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST if ":1234" in llm_config.model_endpoint else INNER_THOUGHTS_KWARG_DESCRIPTION
59
61
  )
60
62
  tools = add_inner_thoughts_to_functions(
61
63
  functions=tools,
@@ -64,22 +66,28 @@ class OpenAIClient(LLMClientBase):
64
66
  put_inner_thoughts_first=True,
65
67
  )
66
68
 
69
+ use_developer_message = llm_config.model.startswith("o1") or llm_config.model.startswith("o3") # o-series models
67
70
  openai_message_list = [
68
- cast_message_to_subtype(m.to_openai_dict(put_inner_thoughts_in_kwargs=self.llm_config.put_inner_thoughts_in_kwargs))
71
+ cast_message_to_subtype(
72
+ m.to_openai_dict(
73
+ put_inner_thoughts_in_kwargs=llm_config.put_inner_thoughts_in_kwargs,
74
+ use_developer_message=use_developer_message,
75
+ )
76
+ )
69
77
  for m in messages
70
78
  ]
71
79
 
72
- if self.llm_config.model:
73
- model = self.llm_config.model
80
+ if llm_config.model:
81
+ model = llm_config.model
74
82
  else:
75
- logger.warning(f"Model type not set in llm_config: {self.llm_config.model_dump_json(indent=4)}")
83
+ logger.warning(f"Model type not set in llm_config: {llm_config.model_dump_json(indent=4)}")
76
84
  model = None
77
85
 
78
86
  # force function calling for reliability, see https://platform.openai.com/docs/api-reference/chat/create#chat-create-tool_choice
79
87
  # TODO(matt) move into LLMConfig
80
88
  # TODO: This vllm checking is very brittle and is a patch at most
81
89
  tool_choice = None
82
- if self.llm_config.model_endpoint == "https://inference.memgpt.ai" or (self.llm_config.handle and "vllm" in self.llm_config.handle):
90
+ if llm_config.model_endpoint == "https://inference.memgpt.ai" or (llm_config.handle and "vllm" in self.llm_config.handle):
83
91
  tool_choice = "auto" # TODO change to "required" once proxy supports it
84
92
  elif tools:
85
93
  # only set if tools is non-Null
@@ -94,11 +102,11 @@ class OpenAIClient(LLMClientBase):
94
102
  tools=[OpenAITool(type="function", function=f) for f in tools] if tools else None,
95
103
  tool_choice=tool_choice,
96
104
  user=str(),
97
- max_completion_tokens=self.llm_config.max_tokens,
98
- temperature=self.llm_config.temperature,
105
+ max_completion_tokens=llm_config.max_tokens,
106
+ temperature=llm_config.temperature,
99
107
  )
100
108
 
101
- if "inference.memgpt.ai" in self.llm_config.model_endpoint:
109
+ if "inference.memgpt.ai" in llm_config.model_endpoint:
102
110
  # override user id for inference.memgpt.ai
103
111
  import uuid
104
112
 
letta/orm/organization.py CHANGED
@@ -51,8 +51,10 @@ class Organization(SqlalchemyBase):
51
51
  providers: Mapped[List["Provider"]] = relationship("Provider", back_populates="organization", cascade="all, delete-orphan")
52
52
  identities: Mapped[List["Identity"]] = relationship("Identity", back_populates="organization", cascade="all, delete-orphan")
53
53
  groups: Mapped[List["Group"]] = relationship("Group", back_populates="organization", cascade="all, delete-orphan")
54
- llm_batch_jobs: Mapped[List["Agent"]] = relationship("LLMBatchJob", back_populates="organization", cascade="all, delete-orphan")
55
- llm_batch_items: Mapped[List["Agent"]] = relationship("LLMBatchItem", back_populates="organization", cascade="all, delete-orphan")
54
+ llm_batch_jobs: Mapped[List["LLMBatchJob"]] = relationship("LLMBatchJob", back_populates="organization", cascade="all, delete-orphan")
55
+ llm_batch_items: Mapped[List["LLMBatchItem"]] = relationship(
56
+ "LLMBatchItem", back_populates="organization", cascade="all, delete-orphan"
57
+ )
56
58
 
57
59
  @property
58
60
  def passages(self) -> List[Union["SourcePassage", "AgentPassage"]]:
@@ -334,6 +334,9 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
334
334
  if len(identifiers) > 0:
335
335
  query = query.where(cls.id.in_(identifiers))
336
336
  query_conditions.append(f"id='{identifiers}'")
337
+ elif not kwargs:
338
+ logger.debug(f"No identifiers provided for {cls.__name__}, returning empty list")
339
+ return []
337
340
 
338
341
  if kwargs:
339
342
  query = query.filter_by(**kwargs)
letta/schemas/enums.py CHANGED
@@ -64,3 +64,4 @@ class ToolRuleType(str, Enum):
64
64
  conditional = "conditional"
65
65
  constrain_child_tools = "constrain_child_tools"
66
66
  max_count_per_step = "max_count_per_step"
67
+ parent_last_tool = "parent_last_tool"
letta/schemas/group.py CHANGED
@@ -42,11 +42,21 @@ class RoundRobinManager(ManagerConfig):
42
42
  max_turns: Optional[int] = Field(None, description="")
43
43
 
44
44
 
45
+ class RoundRobinManagerUpdate(ManagerConfig):
46
+ manager_type: Literal[ManagerType.round_robin] = Field(ManagerType.round_robin, description="")
47
+ max_turns: Optional[int] = Field(None, description="")
48
+
49
+
45
50
  class SupervisorManager(ManagerConfig):
46
51
  manager_type: Literal[ManagerType.supervisor] = Field(ManagerType.supervisor, description="")
47
52
  manager_agent_id: str = Field(..., description="")
48
53
 
49
54
 
55
+ class SupervisorManagerUpdate(ManagerConfig):
56
+ manager_type: Literal[ManagerType.supervisor] = Field(ManagerType.supervisor, description="")
57
+ manager_agent_id: Optional[str] = Field(..., description="")
58
+
59
+
50
60
  class DynamicManager(ManagerConfig):
51
61
  manager_type: Literal[ManagerType.dynamic] = Field(ManagerType.dynamic, description="")
52
62
  manager_agent_id: str = Field(..., description="")
@@ -54,12 +64,25 @@ class DynamicManager(ManagerConfig):
54
64
  max_turns: Optional[int] = Field(None, description="")
55
65
 
56
66
 
67
+ class DynamicManagerUpdate(ManagerConfig):
68
+ manager_type: Literal[ManagerType.dynamic] = Field(ManagerType.dynamic, description="")
69
+ manager_agent_id: Optional[str] = Field(None, description="")
70
+ termination_token: Optional[str] = Field(None, description="")
71
+ max_turns: Optional[int] = Field(None, description="")
72
+
73
+
57
74
  class SleeptimeManager(ManagerConfig):
58
75
  manager_type: Literal[ManagerType.sleeptime] = Field(ManagerType.sleeptime, description="")
59
76
  manager_agent_id: str = Field(..., description="")
60
77
  sleeptime_agent_frequency: Optional[int] = Field(None, description="")
61
78
 
62
79
 
80
+ class SleeptimeManagerUpdate(ManagerConfig):
81
+ manager_type: Literal[ManagerType.sleeptime] = Field(ManagerType.sleeptime, description="")
82
+ manager_agent_id: Optional[str] = Field(None, description="")
83
+ sleeptime_agent_frequency: Optional[int] = Field(None, description="")
84
+
85
+
63
86
  # class SwarmGroup(ManagerConfig):
64
87
  # manager_type: Literal[ManagerType.swarm] = Field(ManagerType.swarm, description="")
65
88
 
@@ -70,6 +93,12 @@ ManagerConfigUnion = Annotated[
70
93
  ]
71
94
 
72
95
 
96
+ ManagerConfigUpdateUnion = Annotated[
97
+ Union[RoundRobinManagerUpdate, SupervisorManagerUpdate, DynamicManagerUpdate, SleeptimeManagerUpdate],
98
+ Field(discriminator="manager_type"),
99
+ ]
100
+
101
+
73
102
  class GroupCreate(BaseModel):
74
103
  agent_ids: List[str] = Field(..., description="")
75
104
  description: str = Field(..., description="")
@@ -80,5 +109,5 @@ class GroupCreate(BaseModel):
80
109
  class GroupUpdate(BaseModel):
81
110
  agent_ids: Optional[List[str]] = Field(None, description="")
82
111
  description: Optional[str] = Field(None, description="")
83
- manager_config: Optional[ManagerConfigUnion] = Field(None, description="")
112
+ manager_config: Optional[ManagerConfigUpdateUnion] = Field(None, description="")
84
113
  shared_block_ids: Optional[List[str]] = Field(None, description="")
letta/schemas/identity.py CHANGED
@@ -61,6 +61,16 @@ class IdentityCreate(LettaBase):
61
61
  properties: Optional[List[IdentityProperty]] = Field(None, description="List of properties associated with the identity.")
62
62
 
63
63
 
64
+ class IdentityUpsert(LettaBase):
65
+ identifier_key: str = Field(..., description="External, user-generated identifier key of the identity.")
66
+ name: str = Field(..., description="The name of the identity.")
67
+ identity_type: IdentityType = Field(..., description="The type of the identity.")
68
+ project_id: Optional[str] = Field(None, description="The project id of the identity, if applicable.")
69
+ agent_ids: Optional[List[str]] = Field(None, description="The agent ids that are associated with the identity.")
70
+ block_ids: Optional[List[str]] = Field(None, description="The IDs of the blocks associated with the identity.")
71
+ properties: Optional[List[IdentityProperty]] = Field(None, description="List of properties associated with the identity.")
72
+
73
+
64
74
  class IdentityUpdate(LettaBase):
65
75
  identifier_key: Optional[str] = Field(None, description="External, user-generated identifier key of the identity.")
66
76
  name: Optional[str] = Field(None, description="The name of the identity.")
@@ -27,3 +27,7 @@ class LettaStreamingRequest(LettaRequest):
27
27
  default=False,
28
28
  description="Flag to determine if individual tokens should be streamed. Set to True for token streaming (requires stream_steps = True).",
29
29
  )
30
+
31
+
32
+ class LettaBatchRequest(LettaRequest):
33
+ agent_id: str = Field(..., description="The ID of the agent to send this batch request for")
@@ -1,12 +1,13 @@
1
1
  import html
2
2
  import json
3
3
  import re
4
+ from datetime import datetime
4
5
  from typing import List, Union
5
6
 
6
7
  from pydantic import BaseModel, Field
7
8
 
8
9
  from letta.helpers.json_helpers import json_dumps
9
- from letta.schemas.enums import MessageStreamStatus
10
+ from letta.schemas.enums import JobStatus, MessageStreamStatus
10
11
  from letta.schemas.letta_message import LettaMessage, LettaMessageUnion
11
12
  from letta.schemas.usage import LettaUsageStatistics
12
13
 
@@ -165,3 +166,10 @@ class LettaResponse(BaseModel):
165
166
 
166
167
  # The streaming response is either [DONE], [DONE_STEP], [DONE], an error, or a LettaMessage
167
168
  LettaStreamingResponse = Union[LettaMessage, MessageStreamStatus, LettaUsageStatistics]
169
+
170
+
171
+ class LettaBatchResponse(BaseModel):
172
+ batch_id: str = Field(..., description="A unique identifier for this batch request.")
173
+ status: JobStatus = Field(..., description="The current status of the batch request.")
174
+ last_polled_at: datetime = Field(..., description="The timestamp when the batch was last polled for updates.")
175
+ created_at: datetime = Field(..., description="The timestamp when the batch request was created.")
@@ -74,6 +74,13 @@ class LLMConfig(BaseModel):
74
74
  # FIXME hack to silence pydantic protected namespace warning
75
75
  model_config = ConfigDict(protected_namespaces=())
76
76
 
77
+ @model_validator(mode="before")
78
+ @classmethod
79
+ def set_default_enable_reasoner(cls, values):
80
+ if any(openai_reasoner_model in values.get("model", "") for openai_reasoner_model in ["o3-mini", "o1"]):
81
+ values["enable_reasoner"] = True
82
+ return values
83
+
77
84
  @model_validator(mode="before")
78
85
  @classmethod
79
86
  def set_default_put_inner_thoughts(cls, values):
@@ -100,6 +107,9 @@ class LLMConfig(BaseModel):
100
107
  logger.warning("max_tokens must be greater than max_reasoning_tokens (thinking budget)")
101
108
  if self.put_inner_thoughts_in_kwargs:
102
109
  logger.warning("Extended thinking is not compatible with put_inner_thoughts_in_kwargs")
110
+ elif self.max_reasoning_tokens and not self.enable_reasoner:
111
+ logger.warning("model will not use reasoning unless enable_reasoner is set to True")
112
+
103
113
  return self
104
114
 
105
115
  @classmethod
letta/schemas/message.py CHANGED
@@ -137,19 +137,26 @@ class Message(BaseMessage):
137
137
  """
138
138
 
139
139
  id: str = BaseMessage.generate_id_field()
140
- role: MessageRole = Field(..., description="The role of the participant.")
141
- content: Optional[List[LettaMessageContentUnion]] = Field(None, description="The content of the message.")
142
140
  organization_id: Optional[str] = Field(None, description="The unique identifier of the organization.")
143
141
  agent_id: Optional[str] = Field(None, description="The unique identifier of the agent.")
144
142
  model: Optional[str] = Field(None, description="The model used to make the function call.")
145
- name: Optional[str] = Field(None, description="The name of the participant.")
146
- tool_calls: Optional[List[OpenAIToolCall]] = Field(None, description="The list of tool calls requested.")
147
- tool_call_id: Optional[str] = Field(None, description="The id of the tool call.")
143
+ # Basic OpenAI-style fields
144
+ role: MessageRole = Field(..., description="The role of the participant.")
145
+ content: Optional[List[LettaMessageContentUnion]] = Field(None, description="The content of the message.")
146
+ # NOTE: in OpenAI, this field is only used for roles 'user', 'assistant', and 'function' (now deprecated). 'tool' does not use it.
147
+ name: Optional[str] = Field(
148
+ None,
149
+ description="For role user/assistant: the (optional) name of the participant. For role tool/function: the name of the function called.",
150
+ )
151
+ tool_calls: Optional[List[OpenAIToolCall]] = Field(
152
+ None, description="The list of tool calls requested. Only applicable for role assistant."
153
+ )
154
+ tool_call_id: Optional[str] = Field(None, description="The ID of the tool call. Only applicable for role tool.")
155
+ # Extras
148
156
  step_id: Optional[str] = Field(None, description="The id of the step that this message was created in.")
149
157
  otid: Optional[str] = Field(None, description="The offline threading id associated with this message")
150
158
  tool_returns: Optional[List[ToolReturn]] = Field(None, description="Tool execution return information for prior tool calls")
151
159
  group_id: Optional[str] = Field(None, description="The multi-agent group that the message was sent in")
152
-
153
160
  # This overrides the optional base orm schema, created_at MUST exist on all messages objects
154
161
  created_at: datetime = Field(default_factory=get_utc_time, description="The timestamp when the object was created.")
155
162
 
@@ -406,7 +413,6 @@ class Message(BaseMessage):
406
413
 
407
414
  @staticmethod
408
415
  def dict_to_message(
409
- user_id: str,
410
416
  agent_id: str,
411
417
  openai_message_dict: dict,
412
418
  model: Optional[str] = None, # model used to make function call
@@ -560,7 +566,7 @@ class Message(BaseMessage):
560
566
  # standard fields expected in an OpenAI ChatCompletion message object
561
567
  role=MessageRole(openai_message_dict["role"]),
562
568
  content=content,
563
- name=name,
569
+ name=openai_message_dict["name"] if "name" in openai_message_dict else name,
564
570
  tool_calls=tool_calls,
565
571
  tool_call_id=openai_message_dict["tool_call_id"] if "tool_call_id" in openai_message_dict else None,
566
572
  created_at=created_at,
@@ -575,7 +581,7 @@ class Message(BaseMessage):
575
581
  # standard fields expected in an OpenAI ChatCompletion message object
576
582
  role=MessageRole(openai_message_dict["role"]),
577
583
  content=content,
578
- name=name,
584
+ name=openai_message_dict["name"] if "name" in openai_message_dict else name,
579
585
  tool_calls=tool_calls,
580
586
  tool_call_id=openai_message_dict["tool_call_id"] if "tool_call_id" in openai_message_dict else None,
581
587
  created_at=created_at,
@@ -592,6 +598,7 @@ class Message(BaseMessage):
592
598
  self,
593
599
  max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
594
600
  put_inner_thoughts_in_kwargs: bool = False,
601
+ use_developer_message: bool = False,
595
602
  ) -> dict:
596
603
  """Go from Message class to ChatCompletion message object"""
597
604
 
@@ -619,7 +626,7 @@ class Message(BaseMessage):
619
626
  assert all([v is not None for v in [self.role]]), vars(self)
620
627
  openai_message = {
621
628
  "content": text_content,
622
- "role": self.role,
629
+ "role": "developer" if use_developer_message else self.role,
623
630
  }
624
631
 
625
632
  elif self.role == "user":
@@ -809,7 +816,7 @@ class Message(BaseMessage):
809
816
  text_content = None
810
817
 
811
818
  if self.role != "tool" and self.name is not None:
812
- warnings.warn(f"Using Google AI with non-null 'name' field ({self.name}) not yet supported.")
819
+ warnings.warn(f"Using Google AI with non-null 'name' field (name={self.name} role={self.role}), not yet supported.")
813
820
 
814
821
  if self.role == "system":
815
822
  # NOTE: Gemini API doesn't have a 'system' role, use 'user' instead
@@ -908,7 +915,9 @@ class Message(BaseMessage):
908
915
  if "parts" not in google_ai_message or not google_ai_message["parts"]:
909
916
  # If parts is empty, add a default text part
910
917
  google_ai_message["parts"] = [{"text": "empty message"}]
911
- warnings.warn(f"Empty 'parts' detected in message with role '{self.role}'. Added default empty text part.")
918
+ warnings.warn(
919
+ f"Empty 'parts' detected in message with role '{self.role}'. Added default empty text part. Full message:\n{vars(self)}"
920
+ )
912
921
 
913
922
  return google_ai_message
914
923
 
@@ -133,6 +133,7 @@ class ChatCompletionRequest(BaseModel):
133
133
  temperature: Optional[float] = 1
134
134
  top_p: Optional[float] = 1
135
135
  user: Optional[str] = None # unique ID of the end-user (for monitoring)
136
+ parallel_tool_calls: Optional[bool] = None
136
137
 
137
138
  # function-calling related
138
139
  tools: Optional[List[Tool]] = None
@@ -29,6 +29,19 @@ class ChildToolRule(BaseToolRule):
29
29
  return set(self.children) if last_tool == self.tool_name else available_tools
30
30
 
31
31
 
32
+ class ParentToolRule(BaseToolRule):
33
+ """
34
+ A ToolRule that only allows a child tool to be called if the parent has been called.
35
+ """
36
+
37
+ type: Literal[ToolRuleType.parent_last_tool] = ToolRuleType.parent_last_tool
38
+ children: List[str] = Field(..., description="The children tools that can be invoked.")
39
+
40
+ def get_valid_tools(self, tool_call_history: List[str], available_tools: Set[str], last_function_response: Optional[str]) -> Set[str]:
41
+ last_tool = tool_call_history[-1] if tool_call_history else None
42
+ return set(self.children) if last_tool == self.tool_name else available_tools - set(self.children)
43
+
44
+
32
45
  class ConditionalToolRule(BaseToolRule):
33
46
  """
34
47
  A ToolRule that conditionally maps to different child tools based on the output.
@@ -128,6 +141,6 @@ class MaxCountPerStepToolRule(BaseToolRule):
128
141
 
129
142
 
130
143
  ToolRule = Annotated[
131
- Union[ChildToolRule, InitToolRule, TerminalToolRule, ConditionalToolRule, ContinueToolRule, MaxCountPerStepToolRule],
144
+ Union[ChildToolRule, InitToolRule, TerminalToolRule, ConditionalToolRule, ContinueToolRule, MaxCountPerStepToolRule, ParentToolRule],
132
145
  Field(discriminator="type"),
133
146
  ]
@@ -29,6 +29,7 @@ from letta.schemas.openai.chat_completion_response import ChatCompletionChunkRes
29
29
  from letta.server.rest_api.optimistic_json_parser import OptimisticJSONParser
30
30
  from letta.streaming_interface import AgentChunkStreamingInterface
31
31
  from letta.streaming_utils import FunctionArgumentsStreamHandler, JSONInnerThoughtsExtractor
32
+ from letta.utils import parse_json
32
33
 
33
34
 
34
35
  # TODO strip from code / deprecate
@@ -408,7 +409,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
408
409
  # if self.expect_reasoning_content_buffer is not None:
409
410
  # try:
410
411
  # # NOTE: this is hardcoded for our DeepSeek API integration
411
- # json_reasoning_content = json.loads(self.expect_reasoning_content_buffer)
412
+ # json_reasoning_content = parse_json(self.expect_reasoning_content_buffer)
412
413
 
413
414
  # if "name" in json_reasoning_content:
414
415
  # self._push_to_buffer(
@@ -528,7 +529,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
528
529
 
529
530
  try:
530
531
  # NOTE: this is hardcoded for our DeepSeek API integration
531
- json_reasoning_content = json.loads(self.expect_reasoning_content_buffer)
532
+ json_reasoning_content = parse_json(self.expect_reasoning_content_buffer)
532
533
  print(f"json_reasoning_content: {json_reasoning_content}")
533
534
 
534
535
  processed_chunk = ToolCallMessage(
@@ -1188,7 +1189,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
1188
1189
  # "date": "2024-06-22T23:04:32.141923+00:00"
1189
1190
  # }
1190
1191
  try:
1191
- func_args = json.loads(function_call.function.arguments)
1192
+ func_args = parse_json(function_call.function.arguments)
1192
1193
  except:
1193
1194
  func_args = function_call.function.arguments
1194
1195
  # processed_chunk = {
@@ -1224,7 +1225,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
1224
1225
  else:
1225
1226
 
1226
1227
  try:
1227
- func_args = json.loads(function_call.function.arguments)
1228
+ func_args = parse_json(function_call.function.arguments)
1228
1229
  except:
1229
1230
  warnings.warn(f"Failed to parse function arguments: {function_call.function.arguments}")
1230
1231
  func_args = {}
@@ -1,14 +1,15 @@
1
1
  import json
2
2
  import traceback
3
3
  from datetime import datetime
4
- from typing import Annotated, List, Optional
4
+ from typing import Annotated, Any, List, Optional
5
5
 
6
6
  from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, Header, HTTPException, Query, UploadFile, status
7
7
  from fastapi.responses import JSONResponse
8
8
  from marshmallow import ValidationError
9
+ from orjson import orjson
9
10
  from pydantic import Field
10
11
  from sqlalchemy.exc import IntegrityError, OperationalError
11
- from starlette.responses import StreamingResponse
12
+ from starlette.responses import Response, StreamingResponse
12
13
 
13
14
  from letta.agents.letta_agent import LettaAgent
14
15
  from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
@@ -22,7 +23,6 @@ from letta.schemas.letta_request import LettaRequest, LettaStreamingRequest
22
23
  from letta.schemas.letta_response import LettaResponse
23
24
  from letta.schemas.memory import ContextWindowOverview, CreateArchivalMemory, Memory
24
25
  from letta.schemas.message import MessageCreate
25
- from letta.schemas.openai.chat_completion_request import UserMessage
26
26
  from letta.schemas.passage import Passage, PassageUpdate
27
27
  from letta.schemas.run import Run
28
28
  from letta.schemas.source import Source
@@ -103,19 +103,30 @@ def list_agents(
103
103
  )
104
104
 
105
105
 
106
- @router.get("/{agent_id}/export", operation_id="export_agent_serialized", response_model=AgentSchema)
106
+ class IndentedORJSONResponse(Response):
107
+ media_type = "application/json"
108
+
109
+ def render(self, content: Any) -> bytes:
110
+ return orjson.dumps(content, option=orjson.OPT_INDENT_2)
111
+
112
+
113
+ @router.get("/{agent_id}/export", response_class=IndentedORJSONResponse, operation_id="export_agent_serialized")
107
114
  def export_agent_serialized(
108
115
  agent_id: str,
109
116
  server: "SyncServer" = Depends(get_letta_server),
110
117
  actor_id: Optional[str] = Header(None, alias="user_id"),
111
- ) -> AgentSchema:
118
+ # do not remove, used to autogeneration of spec
119
+ # TODO: Think of a better way to export AgentSchema
120
+ spec: Optional[AgentSchema] = None,
121
+ ) -> JSONResponse:
112
122
  """
113
- Export the serialized JSON representation of an agent.
123
+ Export the serialized JSON representation of an agent, formatted with indentation.
114
124
  """
115
125
  actor = server.user_manager.get_user_or_default(user_id=actor_id)
116
126
 
117
127
  try:
118
- return server.agent_manager.serialize(agent_id=agent_id, actor=actor)
128
+ agent = server.agent_manager.serialize(agent_id=agent_id, actor=actor)
129
+ return agent.model_dump()
119
130
  except NoResultFound:
120
131
  raise HTTPException(status_code=404, detail=f"Agent with id={agent_id} not found for user_id={actor.id}.")
121
132
 
@@ -610,9 +621,7 @@ async def send_message(
610
621
  actor=actor,
611
622
  )
612
623
 
613
- messages = request.messages
614
- content = messages[0].content[0].text if messages and not isinstance(messages[0].content, str) else messages[0].content
615
- result = await experimental_agent.step(UserMessage(content=content), max_steps=10)
624
+ result = await experimental_agent.step(request.messages, max_steps=10)
616
625
  else:
617
626
  result = await server.send_message_to_agent(
618
627
  agent_id=agent_id,
@@ -672,10 +681,8 @@ async def send_message_streaming(
672
681
  actor=actor,
673
682
  )
674
683
 
675
- messages = request.messages
676
- content = messages[0].content[0].text if messages and not isinstance(messages[0].content, str) else messages[0].content
677
684
  result = StreamingResponse(
678
- experimental_agent.step_stream(UserMessage(content=content), max_steps=10, use_assistant_message=request.use_assistant_message),
685
+ experimental_agent.step_stream(request.messages, max_steps=10, use_assistant_message=request.use_assistant_message),
679
686
  media_type="text/event-stream",
680
687
  )
681
688
  else:
@@ -74,7 +74,7 @@ def create_group(
74
74
  raise HTTPException(status_code=500, detail=str(e))
75
75
 
76
76
 
77
- @router.put("/{group_id}", response_model=Group, operation_id="modify_group")
77
+ @router.patch("/{group_id}", response_model=Group, operation_id="modify_group")
78
78
  def modify_group(
79
79
  group_id: str,
80
80
  group: GroupUpdate = Body(...),
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, List, Optional
3
3
  from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
4
4
 
5
5
  from letta.orm.errors import NoResultFound, UniqueConstraintViolationError
6
- from letta.schemas.identity import Identity, IdentityCreate, IdentityType, IdentityUpdate
6
+ from letta.schemas.identity import Identity, IdentityCreate, IdentityProperty, IdentityType, IdentityUpdate, IdentityUpsert
7
7
  from letta.server.rest_api.utils import get_letta_server
8
8
 
9
9
  if TYPE_CHECKING:
@@ -88,7 +88,7 @@ def create_identity(
88
88
 
89
89
  @router.put("/", tags=["identities"], response_model=Identity, operation_id="upsert_identity")
90
90
  def upsert_identity(
91
- identity: IdentityCreate = Body(...),
91
+ identity: IdentityUpsert = Body(...),
92
92
  server: "SyncServer" = Depends(get_letta_server),
93
93
  actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
94
94
  x_project: Optional[str] = Header(None, alias="X-Project"), # Only handled by next js middleware
@@ -118,6 +118,27 @@ def modify_identity(
118
118
  raise
119
119
  except NoResultFound as e:
120
120
  raise HTTPException(status_code=404, detail=str(e))
121
+ except Exception as e:
122
+ import traceback
123
+
124
+ print(traceback.format_exc())
125
+ raise HTTPException(status_code=500, detail=f"{e}")
126
+
127
+
128
+ @router.put("/{identity_id}/properties", tags=["identities"], operation_id="upsert_identity_properties")
129
+ def upsert_identity_properties(
130
+ identity_id: str,
131
+ properties: List[IdentityProperty] = Body(...),
132
+ server: "SyncServer" = Depends(get_letta_server),
133
+ actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
134
+ ):
135
+ try:
136
+ actor = server.user_manager.get_user_or_default(user_id=actor_id)
137
+ return server.identity_manager.upsert_identity_properties(identity_id=identity_id, properties=properties, actor=actor)
138
+ except HTTPException:
139
+ raise
140
+ except NoResultFound as e:
141
+ raise HTTPException(status_code=404, detail=str(e))
121
142
  except Exception as e:
122
143
  raise HTTPException(status_code=500, detail=f"{e}")
123
144
 
@@ -19,7 +19,7 @@ from letta.helpers.datetime_helpers import get_utc_time
19
19
  from letta.log import get_logger
20
20
  from letta.schemas.enums import MessageRole
21
21
  from letta.schemas.letta_message_content import OmittedReasoningContent, ReasoningContent, RedactedReasoningContent, TextContent
22
- from letta.schemas.message import Message
22
+ from letta.schemas.message import Message, MessageCreate
23
23
  from letta.schemas.usage import LettaUsageStatistics
24
24
  from letta.schemas.user import User
25
25
  from letta.server.rest_api.interface import StreamingServerInterface
@@ -140,31 +140,29 @@ def log_error_to_sentry(e):
140
140
  sentry_sdk.capture_exception(e)
141
141
 
142
142
 
143
- def create_user_message(input_message: dict, agent_id: str, actor: User) -> Message:
143
+ def create_input_messages(input_messages: List[MessageCreate], agent_id: str, actor: User) -> List[Message]:
144
144
  """
145
145
  Converts a user input message into the internal structured format.
146
146
  """
147
- # Generate timestamp in the correct format
148
- # Skip pytz for performance reasons
149
- now = get_utc_time().isoformat()
150
-
151
- # Format message as structured JSON
152
- structured_message = {"type": "user_message", "message": input_message["content"], "time": now}
153
-
154
- # Construct the Message object
155
- user_message = Message(
156
- id=f"message-{uuid.uuid4()}",
157
- role=MessageRole.user,
158
- content=[TextContent(text=json.dumps(structured_message, indent=2))], # Store structured JSON
159
- organization_id=actor.organization_id,
160
- agent_id=agent_id,
161
- model=None,
162
- tool_calls=None,
163
- tool_call_id=None,
164
- created_at=get_utc_time(),
165
- )
147
+ new_messages = []
148
+ for input_message in input_messages:
149
+ # Construct the Message object
150
+ new_message = Message(
151
+ id=f"message-{uuid.uuid4()}",
152
+ role=input_message.role,
153
+ content=input_message.content,
154
+ name=input_message.name,
155
+ otid=input_message.otid,
156
+ organization_id=actor.organization_id,
157
+ agent_id=agent_id,
158
+ model=None,
159
+ tool_calls=None,
160
+ tool_call_id=None,
161
+ created_at=get_utc_time(),
162
+ )
163
+ new_messages.append(new_message)
166
164
 
167
- return user_message
165
+ return new_messages
168
166
 
169
167
 
170
168
  def create_letta_messages_from_llm_response(