letta-nightly 0.5.4.dev20241128000451__py3-none-any.whl → 0.6.0.dev20241204051808__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.

letta/llm_api/helpers.py CHANGED
@@ -11,7 +11,55 @@ from letta.schemas.openai.chat_completion_response import ChatCompletionResponse
11
11
  from letta.utils import json_dumps, printd
12
12
 
13
13
 
14
- def convert_to_structured_output(openai_function: dict) -> dict:
14
+ def _convert_to_structured_output_helper(property: dict) -> dict:
15
+ """Convert a single JSON schema property to structured output format (recursive)"""
16
+
17
+ if "type" not in property:
18
+ raise ValueError(f"Property {property} is missing a type")
19
+ param_type = property["type"]
20
+
21
+ if "description" not in property:
22
+ # raise ValueError(f"Property {property} is missing a description")
23
+ param_description = None
24
+ else:
25
+ param_description = property["description"]
26
+
27
+ if param_type == "object":
28
+ if "properties" not in property:
29
+ raise ValueError(f"Property {property} of type object is missing properties")
30
+ properties = property["properties"]
31
+ property_dict = {
32
+ "type": "object",
33
+ "properties": {k: _convert_to_structured_output_helper(v) for k, v in properties.items()},
34
+ "additionalProperties": False,
35
+ "required": list(properties.keys()),
36
+ }
37
+ if param_description is not None:
38
+ property_dict["description"] = param_description
39
+ return property_dict
40
+
41
+ elif param_type == "array":
42
+ if "items" not in property:
43
+ raise ValueError(f"Property {property} of type array is missing items")
44
+ items = property["items"]
45
+ property_dict = {
46
+ "type": "array",
47
+ "items": _convert_to_structured_output_helper(items),
48
+ }
49
+ if param_description is not None:
50
+ property_dict["description"] = param_description
51
+ return property_dict
52
+
53
+ else:
54
+ property_dict = {
55
+ "type": param_type, # simple type
56
+ }
57
+ if param_description is not None:
58
+ property_dict["description"] = param_description
59
+ return property_dict
60
+
61
+
62
+ def convert_to_structured_output(openai_function: dict, allow_optional: bool = False) -> dict:
15
63
  """Convert function call objects to structured output objects
16
64
 
17
65
  See: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas
@@ -22,17 +70,63 @@ def convert_to_structured_output(openai_function: dict) -> dict:
22
70
  "name": openai_function["name"],
23
71
  "description": description,
24
72
  "strict": True,
25
- "parameters": {"type": "object", "properties": {}, "additionalProperties": False, "required": []},
73
+ "parameters": {
74
+ "type": "object",
75
+ "properties": {},
76
+ "additionalProperties": False,
77
+ "required": [],
78
+ },
26
79
  }
27
80
 
81
+ # This code needs to be able to handle nested properties
82
+ # For example, the param details may have "type" + "description",
83
+ # but if "type" is "object" we expected "properties", where each property has details
84
+ # and if "type" is "array" we expect "items": <type>
28
85
  for param, details in openai_function["parameters"]["properties"].items():
29
- structured_output["parameters"]["properties"][param] = {"type": details["type"], "description": details["description"]}
86
+
87
+ param_type = details["type"]
88
+ description = details["description"]
89
+
90
+ if param_type == "object":
91
+ if "properties" not in details:
92
+ # Structured outputs requires the properties on dicts be specified ahead of time
93
+ raise ValueError(f"Property {param} of type object is missing properties")
94
+ structured_output["parameters"]["properties"][param] = {
95
+ "type": "object",
96
+ "description": description,
97
+ "properties": {k: _convert_to_structured_output_helper(v) for k, v in details["properties"].items()},
98
+ "additionalProperties": False,
99
+ "required": list(details["properties"].keys()),
100
+ }
101
+
102
+ elif param_type == "array":
103
+ structured_output["parameters"]["properties"][param] = {
104
+ "type": "array",
105
+ "description": description,
106
+ "items": _convert_to_structured_output_helper(details["items"]),
107
+ }
108
+
109
+ else:
110
+ structured_output["parameters"]["properties"][param] = {
111
+ "type": param_type, # simple type
112
+ "description": description,
113
+ }
30
114
 
31
115
  if "enum" in details:
32
116
  structured_output["parameters"]["properties"][param]["enum"] = details["enum"]
33
117
 
34
- # Add all properties to required list
35
- structured_output["parameters"]["required"] = list(structured_output["parameters"]["properties"].keys())
118
+ if not allow_optional:
119
+ # Add all properties to required list
120
+ structured_output["parameters"]["required"] = list(structured_output["parameters"]["properties"].keys())
121
+
122
+ else:
123
+ # See what parameters exist that aren't required
124
+ # Those are implied "optional" types
125
+ # For those types, turn each of them into a union type with "null"
126
+ # e.g.
127
+ # "type": "string" -> "type": ["string", "null"]
128
+ # TODO
129
+ raise NotImplementedError
36
130
 
37
131
  return structured_output
38
132
 
letta/llm_api/openai.py CHANGED
@@ -477,7 +477,10 @@ def openai_chat_completions_request_stream(
477
477
  if "tools" in data:
478
478
  for tool in data["tools"]:
479
479
  # tool["strict"] = True
480
- tool["function"] = convert_to_structured_output(tool["function"])
480
+ try:
481
+ tool["function"] = convert_to_structured_output(tool["function"])
482
+ except ValueError as e:
483
+ warnings.warn(f"Failed to convert tool function to structured output, tool={tool}, error={e}")
481
484
 
482
485
  # print(f"\n\n\n\nData[tools]: {json.dumps(data['tools'], indent=2)}")
483
486
 
@@ -533,7 +536,10 @@ def openai_chat_completions_request(
533
536
 
534
537
  if "tools" in data:
535
538
  for tool in data["tools"]:
536
- tool["function"] = convert_to_structured_output(tool["function"])
539
+ try:
540
+ tool["function"] = convert_to_structured_output(tool["function"])
541
+ except ValueError as e:
542
+ warnings.warn(f"Failed to convert tool function to structured output, tool={tool}, error={e}")
537
543
 
538
544
  response_json = make_post_request(url, headers, data)
539
545
  return ChatCompletionResponse(**response_json)
letta/local_llm/utils.py CHANGED
@@ -88,7 +88,9 @@ def num_tokens_from_functions(functions: List[dict], model: str = "gpt-4"):
88
88
  try:
89
89
  encoding = tiktoken.encoding_for_model(model)
90
90
  except KeyError:
91
- warnings.warn("Warning: model not found. Using cl100k_base encoding.")
91
+ from letta.utils import printd
92
+
93
+ printd(f"Warning: model not found. Using cl100k_base encoding.")
92
94
  encoding = tiktoken.get_encoding("cl100k_base")
93
95
 
94
96
  num_tokens = 0
@@ -121,7 +123,7 @@ def num_tokens_from_functions(functions: List[dict], model: str = "gpt-4"):
121
123
  function_tokens += 3
122
124
  function_tokens += len(encoding.encode(o))
123
125
  else:
124
- print(f"Warning: not supported field {field}")
126
+ warnings.warn(f"num_tokens_from_functions: Unsupported field {field} in function {function}")
125
127
  function_tokens += 11
126
128
 
127
129
  num_tokens += function_tokens
@@ -215,8 +217,10 @@ def num_tokens_from_messages(messages: List[dict], model: str = "gpt-4") -> int:
215
217
  # print("Warning: gpt-4 may update over time. Returning num tokens assuming gpt-4-0613.")
216
218
  return num_tokens_from_messages(messages, model="gpt-4-0613")
217
219
  else:
218
- warnings.warn(
219
- f"""num_tokens_from_messages() is not implemented for model {model}. See https://github.com/openai/openai-python/blob/main/chatml.md for information on how messages are converted to tokens."""
220
+ from letta.utils import printd
221
+
222
+ printd(
223
+ f"num_tokens_from_messages() is not implemented for model {model}. See https://github.com/openai/openai-python/blob/main/chatml.md for information on how messages are converted to tokens."
220
224
  )
221
225
  return num_tokens_from_messages(messages, model="gpt-4-0613")
222
226
  # raise NotImplementedError(
letta/metadata.py CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import os
4
4
  import secrets
5
- import warnings
6
5
  from typing import List, Optional, Union
7
6
 
8
7
  from sqlalchemy import JSON, Column, DateTime, Index, String, TypeDecorator
@@ -359,8 +358,8 @@ class MetadataStore:
359
358
  # warnings.warn(f"Agent {agent.id} has no _internal_memory field")
360
359
  if "tags" in fields:
361
360
  del fields["tags"]
362
- else:
363
- warnings.warn(f"Agent {agent.id} has no tags field")
361
+ # else:
362
+ # warnings.warn(f"Agent {agent.id} has no tags field")
364
363
  session.add(AgentModel(**fields))
365
364
  session.commit()
366
365
 
@@ -376,8 +375,8 @@ class MetadataStore:
376
375
  # warnings.warn(f"Agent {agent.id} has no _internal_memory field")
377
376
  if "tags" in fields:
378
377
  del fields["tags"]
379
- else:
380
- warnings.warn(f"Agent {agent.id} has no tags field")
378
+ # else:
379
+ # warnings.warn(f"Agent {agent.id} has no tags field")
381
380
  session.query(AgentModel).filter(AgentModel.id == agent.id).update(fields)
382
381
  session.commit()
383
382
 
letta/schemas/block.py CHANGED
@@ -3,6 +3,7 @@ from typing import Optional
3
3
  from pydantic import BaseModel, Field, model_validator
4
4
  from typing_extensions import Self
5
5
 
6
+ from letta.constants import CORE_MEMORY_BLOCK_CHAR_LIMIT
6
7
  from letta.schemas.letta_base import LettaBase
7
8
 
8
9
  # block of the LLM context
@@ -15,7 +16,7 @@ class BaseBlock(LettaBase, validate_assignment=True):
15
16
 
16
17
  # data value
17
18
  value: str = Field(..., description="Value of the block.")
18
- limit: int = Field(2000, description="Character limit of the block.")
19
+ limit: int = Field(CORE_MEMORY_BLOCK_CHAR_LIMIT, description="Character limit of the block.")
19
20
 
20
21
  # template data (optional)
21
22
  template_name: Optional[str] = Field(None, description="Name of the block if it is a template.", alias="name")
@@ -117,7 +118,7 @@ class BlockLabelUpdate(BaseModel):
117
118
  class BlockUpdate(BaseBlock):
118
119
  """Update a block"""
119
120
 
120
- limit: Optional[int] = Field(2000, description="Character limit of the block.")
121
+ limit: Optional[int] = Field(CORE_MEMORY_BLOCK_CHAR_LIMIT, description="Character limit of the block.")
121
122
  value: Optional[str] = Field(None, description="Value of the block.")
122
123
 
123
124
  class Config:
@@ -147,7 +148,7 @@ class CreateBlock(BaseBlock):
147
148
  """Create a block"""
148
149
 
149
150
  label: str = Field(..., description="Label of the block.")
150
- limit: int = Field(2000, description="Character limit of the block.")
151
+ limit: int = Field(CORE_MEMORY_BLOCK_CHAR_LIMIT, description="Character limit of the block.")
151
152
  value: str = Field(..., description="Value of the block.")
152
153
 
153
154
  # block templates
letta/schemas/tool.py CHANGED
@@ -201,3 +201,15 @@ class ToolUpdate(LettaBase):
201
201
  class Config:
202
202
  extra = "ignore" # Allows extra fields without validation errors
203
203
  # TODO: Remove this, and clean usage of ToolUpdate everywhere else
204
+
205
+
206
+ class ToolRun(LettaBase):
207
+ id: str = Field(..., description="The ID of the tool to run.")
208
+ args: str = Field(..., description="The arguments to pass to the tool (as stringified JSON).")
209
+
210
+
211
+ class ToolRunFromSource(LettaBase):
212
+ source_code: str = Field(..., description="The source code of the function.")
213
+ args: str = Field(..., description="The arguments to pass to the tool (as stringified JSON).")
214
+ name: Optional[str] = Field(None, description="The name of the tool to run.")
215
+ source_type: Optional[str] = Field(None, description="The type of the source code.")
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import logging
3
+ import os
3
4
  import sys
4
5
  from pathlib import Path
5
6
  from typing import Optional
@@ -103,7 +104,7 @@ def generate_password():
103
104
  return secrets.token_urlsafe(16)
104
105
 
105
106
 
106
- random_password = generate_password()
107
+ random_password = os.getenv("LETTA_SERVER_PASSWORD") or generate_password()
107
108
 
108
109
 
109
110
  class CheckPasswordMiddleware(BaseHTTPMiddleware):
@@ -132,11 +133,11 @@ def create_application() -> "FastAPI":
132
133
  debug=True,
133
134
  )
134
135
 
135
- if "--ade" in sys.argv:
136
+ if (os.getenv("LETTA_SERVER_ADE") == "true") or "--ade" in sys.argv:
136
137
  settings.cors_origins.append("https://app.letta.com")
137
- print(f"▶ View using ADE at: https://app.letta.com/local-project/agents")
138
+ print(f"▶ View using ADE at: https://app.letta.com/development-servers/local/dashboard")
138
139
 
139
- if "--secure" in sys.argv:
140
+ if (os.getenv("LETTA_SERVER_SECURE") == "true") or "--secure" in sys.argv:
140
141
  print(f"▶ Using secure mode with password: {random_password}")
141
142
  app.add_middleware(CheckPasswordMiddleware)
142
143
 
@@ -448,21 +448,18 @@ async def send_message(
448
448
  This endpoint accepts a message from a user and processes it through the agent.
449
449
  """
450
450
  actor = server.get_user_or_default(user_id=user_id)
451
-
452
- agent_lock = server.per_agent_lock_manager.get_lock(agent_id)
453
- async with agent_lock:
454
- result = await send_message_to_agent(
455
- server=server,
456
- agent_id=agent_id,
457
- user_id=actor.id,
458
- messages=request.messages,
459
- stream_steps=False,
460
- stream_tokens=False,
461
- # Support for AssistantMessage
462
- assistant_message_tool_name=request.assistant_message_tool_name,
463
- assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
464
- )
465
- return result
451
+ result = await send_message_to_agent(
452
+ server=server,
453
+ agent_id=agent_id,
454
+ user_id=actor.id,
455
+ messages=request.messages,
456
+ stream_steps=False,
457
+ stream_tokens=False,
458
+ # Support for AssistantMessage
459
+ assistant_message_tool_name=request.assistant_message_tool_name,
460
+ assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
461
+ )
462
+ return result
466
463
 
467
464
 
468
465
  @router.post(
@@ -490,21 +487,18 @@ async def send_message_streaming(
490
487
  It will stream the steps of the response always, and stream the tokens if 'stream_tokens' is set to True.
491
488
  """
492
489
  actor = server.get_user_or_default(user_id=user_id)
493
-
494
- agent_lock = server.per_agent_lock_manager.get_lock(agent_id)
495
- async with agent_lock:
496
- result = await send_message_to_agent(
497
- server=server,
498
- agent_id=agent_id,
499
- user_id=actor.id,
500
- messages=request.messages,
501
- stream_steps=True,
502
- stream_tokens=request.stream_tokens,
503
- # Support for AssistantMessage
504
- assistant_message_tool_name=request.assistant_message_tool_name,
505
- assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
506
- )
507
- return result
490
+ result = await send_message_to_agent(
491
+ server=server,
492
+ agent_id=agent_id,
493
+ user_id=actor.id,
494
+ messages=request.messages,
495
+ stream_steps=True,
496
+ stream_tokens=request.stream_tokens,
497
+ # Support for AssistantMessage
498
+ assistant_message_tool_name=request.assistant_message_tool_name,
499
+ assistant_message_tool_kwarg=request.assistant_message_tool_kwarg,
500
+ )
501
+ return result
508
502
 
509
503
 
510
504
  # TODO: move this into server.py?
@@ -8,6 +8,7 @@ from letta.schemas.sandbox_config import SandboxEnvironmentVariable as PydanticE
8
8
  from letta.schemas.sandbox_config import (
9
9
  SandboxEnvironmentVariableCreate,
10
10
  SandboxEnvironmentVariableUpdate,
11
+ SandboxType,
11
12
  )
12
13
  from letta.server.rest_api.utils import get_letta_server, get_user_id
13
14
  from letta.server.server import SyncServer
@@ -29,6 +30,24 @@ def create_sandbox_config(
29
30
  return server.sandbox_config_manager.create_or_update_sandbox_config(config_create, actor)
30
31
 
31
32
 
33
+ @router.post("/e2b/default", response_model=PydanticSandboxConfig)
34
+ def create_default_e2b_sandbox_config(
35
+ server: SyncServer = Depends(get_letta_server),
36
+ user_id: str = Depends(get_user_id),
37
+ ):
38
+ actor = server.get_user_or_default(user_id=user_id)
39
+ return server.sandbox_config_manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.E2B, actor=actor)
40
+
41
+
42
+ @router.post("/local/default", response_model=PydanticSandboxConfig)
43
+ def create_default_local_sandbox_config(
44
+ server: SyncServer = Depends(get_letta_server),
45
+ user_id: str = Depends(get_user_id),
46
+ ):
47
+ actor = server.get_user_or_default(user_id=user_id)
48
+ return server.sandbox_config_manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.LOCAL, actor=actor)
49
+
50
+
32
51
  @router.patch("/{sandbox_config_id}", response_model=PydanticSandboxConfig)
33
52
  def update_sandbox_config(
34
53
  sandbox_config_id: str,
@@ -37,7 +37,10 @@ def get_source(
37
37
  """
38
38
  actor = server.get_user_or_default(user_id=user_id)
39
39
 
40
- return server.source_manager.get_source_by_id(source_id=source_id, actor=actor)
40
+ source = server.source_manager.get_source_by_id(source_id=source_id, actor=actor)
41
+ if not source:
42
+ raise HTTPException(status_code=404, detail=f"Source with id={source_id} not found.")
43
+ return source
41
44
 
42
45
 
43
46
  @router.get("/name/{source_name}", response_model=str, operation_id="get_source_id_by_name")
@@ -52,6 +55,8 @@ def get_source_id_by_name(
52
55
  actor = server.get_user_or_default(user_id=user_id)
53
56
 
54
57
  source = server.source_manager.get_source_by_name(source_name=source_name, actor=actor)
58
+ if not source:
59
+ raise HTTPException(status_code=404, detail=f"Source with name={source_name} not found.")
55
60
  return source.id
56
61
 
57
62
 
@@ -94,6 +99,8 @@ def update_source(
94
99
  Update the name or documentation of an existing data source.
95
100
  """
96
101
  actor = server.get_user_or_default(user_id=user_id)
102
+ if not server.source_manager.get_source_by_id(source_id=source_id, actor=actor):
103
+ raise HTTPException(status_code=404, detail=f"Source with id={source_id} does not exist.")
97
104
  return server.source_manager.update_source(source_id=source_id, source_update=source, actor=actor)
98
105
 
99
106
 
@@ -1,10 +1,12 @@
1
1
  from typing import List, Optional
2
2
 
3
+ from composio.client.collections import ActionModel, AppModel
3
4
  from fastapi import APIRouter, Body, Depends, Header, HTTPException
4
5
 
5
6
  from letta.errors import LettaToolCreateError
6
7
  from letta.orm.errors import UniqueConstraintViolationError
7
- from letta.schemas.tool import Tool, ToolCreate, ToolUpdate
8
+ from letta.schemas.letta_message import FunctionReturn
9
+ from letta.schemas.tool import Tool, ToolCreate, ToolRunFromSource, ToolUpdate
8
10
  from letta.server.rest_api.utils import get_letta_server
9
11
  from letta.server.server import SyncServer
10
12
 
@@ -156,3 +158,88 @@ def add_base_tools(
156
158
  """
157
159
  actor = server.get_user_or_default(user_id=user_id)
158
160
  return server.tool_manager.add_base_tools(actor=actor)
161
+
162
+
163
+ # NOTE: can re-enable if needed
164
+ # @router.post("/{tool_id}/run", response_model=FunctionReturn, operation_id="run_tool")
165
+ # def run_tool(
166
+ # server: SyncServer = Depends(get_letta_server),
167
+ # request: ToolRun = Body(...),
168
+ # user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
169
+ # ):
170
+ # """
171
+ # Run an existing tool on provided arguments
172
+ # """
173
+ # actor = server.get_user_or_default(user_id=user_id)
174
+
175
+ # return server.run_tool(tool_id=request.tool_id, tool_args=request.tool_args, user_id=actor.id)
176
+
177
+
178
+ @router.post("/run", response_model=FunctionReturn, operation_id="run_tool_from_source")
179
+ def run_tool_from_source(
180
+ server: SyncServer = Depends(get_letta_server),
181
+ request: ToolRunFromSource = Body(...),
182
+ user_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
183
+ ):
184
+ """
185
+ Attempt to build a tool from source, then run it on the provided arguments
186
+ """
187
+ actor = server.get_user_or_default(user_id=user_id)
188
+
189
+ try:
190
+ return server.run_tool_from_source(
191
+ tool_source=request.source_code,
192
+ tool_source_type=request.source_type,
193
+ tool_args=request.args,
194
+ tool_name=request.name,
195
+ user_id=actor.id,
196
+ )
197
+ except LettaToolCreateError as e:
198
+ # HTTP 400 == Bad Request
199
+ print(f"Error occurred during tool creation: {e}")
200
+ # print the full stack trace
201
+ import traceback
202
+
203
+ print(traceback.format_exc())
204
+ raise HTTPException(status_code=400, detail=str(e))
205
+
206
+ except Exception as e:
207
+ # Catch other unexpected errors and raise an internal server error
208
+ print(f"Unexpected error occurred: {e}")
209
+ raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
210
+
211
+
212
+ # Specific routes for Composio
213
+
214
+
215
+ @router.get("/composio/apps", response_model=List[AppModel], operation_id="list_composio_apps")
216
+ def list_composio_apps(server: SyncServer = Depends(get_letta_server)):
217
+ """
218
+ Get a list of all Composio apps
219
+ """
220
+ return server.get_composio_apps()
221
+
222
+
223
+ @router.get("/composio/apps/{composio_app_name}/actions", response_model=List[ActionModel], operation_id="list_composio_actions_by_app")
224
+ def list_composio_actions_by_app(
225
+ composio_app_name: str,
226
+ server: SyncServer = Depends(get_letta_server),
227
+ ):
228
+ """
229
+ Get a list of all Composio actions for a specific app
230
+ """
231
+ return server.get_composio_actions_from_app_name(composio_app_name=composio_app_name)
232
+
233
+
234
+ @router.post("/composio/{composio_action_name}", response_model=Tool, operation_id="add_composio_tool")
235
+ def add_composio_tool(
236
+ composio_action_name: str,
237
+ server: SyncServer = Depends(get_letta_server),
238
+ user_id: Optional[str] = Header(None, alias="user_id"),
239
+ ):
240
+ """
241
+ Add a new Composio tool by action name (Composio refers to each tool as an `Action`)
242
+ """
243
+ actor = server.get_user_or_default(user_id=user_id)
244
+ tool_create = ToolCreate.from_composio(action=composio_action_name)
245
+ return server.tool_manager.create_or_update_tool(pydantic_tool=Tool(**tool_create.model_dump()), actor=actor)