letta-nightly 0.8.4.dev20250614104137__py3-none-any.whl → 0.8.4.dev20250615221417__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 -0
  2. letta/agents/base_agent.py +12 -1
  3. letta/agents/helpers.py +5 -2
  4. letta/agents/letta_agent.py +98 -61
  5. letta/agents/voice_sleeptime_agent.py +2 -1
  6. letta/constants.py +3 -5
  7. letta/data_sources/redis_client.py +30 -10
  8. letta/functions/function_sets/files.py +4 -4
  9. letta/functions/helpers.py +6 -1
  10. letta/functions/mcp_client/types.py +95 -0
  11. letta/groups/sleeptime_multi_agent_v2.py +2 -1
  12. letta/helpers/decorators.py +91 -0
  13. letta/interfaces/anthropic_streaming_interface.py +11 -0
  14. letta/interfaces/openai_streaming_interface.py +244 -225
  15. letta/llm_api/openai_client.py +1 -1
  16. letta/local_llm/utils.py +5 -1
  17. letta/orm/enums.py +1 -0
  18. letta/orm/mcp_server.py +3 -0
  19. letta/orm/tool.py +3 -0
  20. letta/otel/metric_registry.py +12 -0
  21. letta/otel/metrics.py +16 -7
  22. letta/schemas/letta_response.py +6 -1
  23. letta/schemas/letta_stop_reason.py +22 -0
  24. letta/schemas/mcp.py +48 -6
  25. letta/schemas/openai/chat_completion_request.py +1 -1
  26. letta/schemas/openai/chat_completion_response.py +1 -1
  27. letta/schemas/pip_requirement.py +14 -0
  28. letta/schemas/sandbox_config.py +1 -19
  29. letta/schemas/tool.py +5 -0
  30. letta/server/rest_api/json_parser.py +39 -3
  31. letta/server/rest_api/routers/v1/tools.py +3 -1
  32. letta/server/rest_api/routers/v1/voice.py +2 -3
  33. letta/server/rest_api/utils.py +1 -1
  34. letta/server/server.py +11 -2
  35. letta/services/agent_manager.py +37 -29
  36. letta/services/helpers/tool_execution_helper.py +39 -9
  37. letta/services/mcp/base_client.py +13 -2
  38. letta/services/mcp/sse_client.py +8 -1
  39. letta/services/mcp/streamable_http_client.py +56 -0
  40. letta/services/mcp_manager.py +23 -9
  41. letta/services/message_manager.py +30 -3
  42. letta/services/tool_executor/files_tool_executor.py +2 -3
  43. letta/services/tool_sandbox/e2b_sandbox.py +53 -3
  44. letta/services/tool_sandbox/local_sandbox.py +3 -1
  45. letta/services/user_manager.py +22 -0
  46. letta/settings.py +3 -0
  47. {letta_nightly-0.8.4.dev20250614104137.dist-info → letta_nightly-0.8.4.dev20250615221417.dist-info}/METADATA +5 -6
  48. {letta_nightly-0.8.4.dev20250614104137.dist-info → letta_nightly-0.8.4.dev20250615221417.dist-info}/RECORD +51 -48
  49. {letta_nightly-0.8.4.dev20250614104137.dist-info → letta_nightly-0.8.4.dev20250615221417.dist-info}/LICENSE +0 -0
  50. {letta_nightly-0.8.4.dev20250614104137.dist-info → letta_nightly-0.8.4.dev20250615221417.dist-info}/WHEEL +0 -0
  51. {letta_nightly-0.8.4.dev20250614104137.dist-info → letta_nightly-0.8.4.dev20250615221417.dist-info}/entry_points.txt +0 -0
letta/otel/metrics.py CHANGED
@@ -5,18 +5,19 @@ from typing import List
5
5
  from fastapi import FastAPI, Request
6
6
  from opentelemetry import metrics
7
7
  from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
8
- from opentelemetry.metrics import NoOpMeter
9
- from opentelemetry.sdk.metrics import MeterProvider
10
- from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
8
+ from opentelemetry.metrics import Meter, NoOpMeter
9
+ from opentelemetry.sdk.metrics import Counter, Histogram, MeterProvider
10
+ from opentelemetry.sdk.metrics.export import AggregationTemporality, PeriodicExportingMetricReader
11
11
 
12
12
  from letta.helpers.datetime_helpers import ns_to_ms
13
13
  from letta.log import get_logger
14
14
  from letta.otel.context import add_ctx_attribute, get_ctx_attributes
15
15
  from letta.otel.resource import get_resource, is_pytest_environment
16
+ from letta.settings import settings
16
17
 
17
18
  logger = get_logger(__name__)
18
19
 
19
- _meter: metrics.Meter = NoOpMeter("noop")
20
+ _meter: Meter = NoOpMeter("noop")
20
21
  _is_metrics_initialized: bool = False
21
22
 
22
23
  # Endpoints to include in endpoint metrics tracking (opt-in) vs tracing.py opt-out
@@ -110,9 +111,17 @@ def setup_metrics(
110
111
  assert endpoint
111
112
 
112
113
  global _is_metrics_initialized, _meter
113
-
114
- otlp_metric_exporter = OTLPMetricExporter(endpoint=endpoint)
114
+ preferred_temporality = AggregationTemporality(settings.otel_preferred_temporality)
115
+ otlp_metric_exporter = OTLPMetricExporter(
116
+ endpoint=endpoint,
117
+ preferred_temporality={
118
+ # Add more as needed here.
119
+ Counter: preferred_temporality,
120
+ Histogram: preferred_temporality,
121
+ },
122
+ )
115
123
  metric_reader = PeriodicExportingMetricReader(exporter=otlp_metric_exporter)
124
+
116
125
  meter_provider = MeterProvider(resource=get_resource(service_name), metric_readers=[metric_reader])
117
126
  metrics.set_meter_provider(meter_provider)
118
127
  _meter = metrics.get_meter(__name__)
@@ -123,7 +132,7 @@ def setup_metrics(
123
132
  _is_metrics_initialized = True
124
133
 
125
134
 
126
- def get_letta_meter() -> metrics.Meter | None:
135
+ def get_letta_meter() -> Meter:
127
136
  """Returns the global letta meter if metrics are initialized."""
128
137
  if not _is_metrics_initialized or isinstance(_meter, NoOpMeter):
129
138
  logger.warning("Metrics are not initialized or meter is not available.")
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
9
9
  from letta.helpers.json_helpers import json_dumps
10
10
  from letta.schemas.enums import JobStatus, MessageStreamStatus
11
11
  from letta.schemas.letta_message import LettaMessage, LettaMessageUnion
12
+ from letta.schemas.letta_stop_reason import LettaStopReason
12
13
  from letta.schemas.message import Message
13
14
  from letta.schemas.usage import LettaUsageStatistics
14
15
 
@@ -34,6 +35,10 @@ class LettaResponse(BaseModel):
34
35
  }
35
36
  },
36
37
  )
38
+ stop_reason: LettaStopReason = Field(
39
+ ...,
40
+ description="The stop reason from Letta indicating why agent loop stopped execution.",
41
+ )
37
42
  usage: LettaUsageStatistics = Field(
38
43
  ...,
39
44
  description="The usage statistics of the agent.",
@@ -166,7 +171,7 @@ class LettaResponse(BaseModel):
166
171
 
167
172
 
168
173
  # The streaming response is either [DONE], [DONE_STEP], [DONE], an error, or a LettaMessage
169
- LettaStreamingResponse = Union[LettaMessage, MessageStreamStatus, LettaUsageStatistics]
174
+ LettaStreamingResponse = Union[LettaMessage, MessageStreamStatus, LettaStopReason, LettaUsageStatistics]
170
175
 
171
176
 
172
177
  class LettaBatchResponse(BaseModel):
@@ -0,0 +1,22 @@
1
+ from enum import Enum
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class StopReasonType(str, Enum):
8
+ end_turn = "end_turn"
9
+ error = "error"
10
+ invalid_tool_call = "invalid_tool_call"
11
+ max_steps = "max_steps"
12
+ no_tool_call = "no_tool_call"
13
+ tool_rule = "tool_rule"
14
+
15
+
16
+ class LettaStopReason(BaseModel):
17
+ """
18
+ The stop reason from Letta indicating why agent loop stopped execution.
19
+ """
20
+
21
+ message_type: Literal["stop_reason"] = Field("stop_reason", description="The type of the message.")
22
+ stop_reason: StopReasonType = Field(..., description="The reason why execution stopped.")
letta/schemas/mcp.py CHANGED
@@ -2,7 +2,14 @@ from typing import Any, Dict, Optional, Union
2
2
 
3
3
  from pydantic import Field
4
4
 
5
- from letta.functions.mcp_client.types import MCPServerType, SSEServerConfig, StdioServerConfig
5
+ from letta.functions.mcp_client.types import (
6
+ MCP_AUTH_HEADER_AUTHORIZATION,
7
+ MCP_AUTH_TOKEN_BEARER_PREFIX,
8
+ MCPServerType,
9
+ SSEServerConfig,
10
+ StdioServerConfig,
11
+ StreamableHTTPServerConfig,
12
+ )
6
13
  from letta.schemas.letta_base import LettaBase
7
14
 
8
15
 
@@ -17,6 +24,7 @@ class MCPServer(BaseMCPServer):
17
24
 
18
25
  # sse config
19
26
  server_url: Optional[str] = Field(None, description="The URL of the server (MCP SSE client will connect to this URL)")
27
+ token: Optional[str] = Field(None, description="The access token or API key for the MCP server (used for SSE authentication)")
20
28
 
21
29
  # stdio config
22
30
  stdio_config: Optional[StdioServerConfig] = Field(
@@ -30,22 +38,38 @@ class MCPServer(BaseMCPServer):
30
38
  last_updated_by_id: Optional[str] = Field(None, description="The id of the user that made this Tool.")
31
39
  metadata_: Optional[Dict[str, Any]] = Field(default_factory=dict, description="A dictionary of additional metadata for the tool.")
32
40
 
33
- # TODO: add tokens?
34
-
35
- def to_config(self) -> Union[SSEServerConfig, StdioServerConfig]:
41
+ def to_config(self) -> Union[SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig]:
36
42
  if self.server_type == MCPServerType.SSE:
37
43
  return SSEServerConfig(
38
44
  server_name=self.server_name,
39
45
  server_url=self.server_url,
46
+ auth_header=MCP_AUTH_HEADER_AUTHORIZATION if self.token else None,
47
+ auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {self.token}" if self.token else None,
48
+ custom_headers=None,
40
49
  )
41
50
  elif self.server_type == MCPServerType.STDIO:
51
+ if self.stdio_config is None:
52
+ raise ValueError("stdio_config is required for STDIO server type")
42
53
  return self.stdio_config
54
+ elif self.server_type == MCPServerType.STREAMABLE_HTTP:
55
+ if self.server_url is None:
56
+ raise ValueError("server_url is required for STREAMABLE_HTTP server type")
57
+ return StreamableHTTPServerConfig(
58
+ server_name=self.server_name,
59
+ server_url=self.server_url,
60
+ auth_header=MCP_AUTH_HEADER_AUTHORIZATION if self.token else None,
61
+ auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {self.token}" if self.token else None,
62
+ custom_headers=None,
63
+ )
64
+ else:
65
+ raise ValueError(f"Unsupported server type: {self.server_type}")
43
66
 
44
67
 
45
68
  class RegisterSSEMCPServer(LettaBase):
46
69
  server_name: str = Field(..., description="The name of the server")
47
70
  server_type: MCPServerType = MCPServerType.SSE
48
71
  server_url: str = Field(..., description="The URL of the server (MCP SSE client will connect to this URL)")
72
+ token: Optional[str] = Field(None, description="The access token or API key for the MCP server used for authentication")
49
73
 
50
74
 
51
75
  class RegisterStdioMCPServer(LettaBase):
@@ -54,11 +78,20 @@ class RegisterStdioMCPServer(LettaBase):
54
78
  stdio_config: StdioServerConfig = Field(..., description="The configuration for the server (MCP 'local' client will run this command)")
55
79
 
56
80
 
81
+ class RegisterStreamableHTTPMCPServer(LettaBase):
82
+ server_name: str = Field(..., description="The name of the server")
83
+ server_type: MCPServerType = MCPServerType.STREAMABLE_HTTP
84
+ server_url: str = Field(..., description="The URL path for the streamable HTTP server (e.g., 'example/mcp')")
85
+ auth_header: Optional[str] = Field(None, description="The name of the authentication header (e.g., 'Authorization')")
86
+ auth_token: Optional[str] = Field(None, description="The authentication token or API key value")
87
+
88
+
57
89
  class UpdateSSEMCPServer(LettaBase):
58
90
  """Update an SSE MCP server"""
59
91
 
60
92
  server_name: Optional[str] = Field(None, description="The name of the server")
61
93
  server_url: Optional[str] = Field(None, description="The URL of the server (MCP SSE client will connect to this URL)")
94
+ token: Optional[str] = Field(None, description="The access token or API key for the MCP server (used for SSE authentication)")
62
95
 
63
96
 
64
97
  class UpdateStdioMCPServer(LettaBase):
@@ -70,5 +103,14 @@ class UpdateStdioMCPServer(LettaBase):
70
103
  )
71
104
 
72
105
 
73
- UpdateMCPServer = Union[UpdateSSEMCPServer, UpdateStdioMCPServer]
74
- RegisterMCPServer = Union[RegisterSSEMCPServer, RegisterStdioMCPServer]
106
+ class UpdateStreamableHTTPMCPServer(LettaBase):
107
+ """Update a Streamable HTTP MCP server"""
108
+
109
+ server_name: Optional[str] = Field(None, description="The name of the server")
110
+ server_url: Optional[str] = Field(None, description="The URL path for the streamable HTTP server (e.g., 'example/mcp')")
111
+ auth_header: Optional[str] = Field(None, description="The name of the authentication header (e.g., 'Authorization')")
112
+ auth_token: Optional[str] = Field(None, description="The authentication token or API key value")
113
+
114
+
115
+ UpdateMCPServer = Union[UpdateSSEMCPServer, UpdateStdioMCPServer, UpdateStreamableHTTPMCPServer]
116
+ RegisterMCPServer = Union[RegisterSSEMCPServer, RegisterStdioMCPServer, RegisterStreamableHTTPMCPServer]
@@ -123,7 +123,7 @@ class ChatCompletionRequest(BaseModel):
123
123
  logit_bias: Optional[Dict[str, int]] = None
124
124
  logprobs: Optional[bool] = False
125
125
  top_logprobs: Optional[int] = None
126
- max_tokens: Optional[int] = None
126
+ max_completion_tokens: Optional[int] = None
127
127
  n: Optional[int] = 1
128
128
  presence_penalty: Optional[float] = 0
129
129
  response_format: Optional[ResponseFormat] = None
@@ -62,7 +62,7 @@ class Message(BaseModel):
62
62
  reasoning_content: Optional[str] = None # Used in newer reasoning APIs, e.g. DeepSeek
63
63
  reasoning_content_signature: Optional[str] = None # NOTE: for Anthropic
64
64
  redacted_reasoning_content: Optional[str] = None # NOTE: for Anthropic
65
- ommitted_reasoning_content: bool = False # NOTE: for OpenAI o1/o3
65
+ omitted_reasoning_content: bool = False # NOTE: for OpenAI o1/o3
66
66
 
67
67
 
68
68
  class Choice(BaseModel):
@@ -0,0 +1,14 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class PipRequirement(BaseModel):
7
+ name: str = Field(..., min_length=1, description="Name of the pip package.")
8
+ version: Optional[str] = Field(None, description="Optional version of the package, following semantic versioning.")
9
+
10
+ def __str__(self) -> str:
11
+ """Return a pip-installable string format."""
12
+ if self.version:
13
+ return f"{self.name}=={self.version}"
14
+ return self.name
@@ -1,6 +1,5 @@
1
1
  import hashlib
2
2
  import json
3
- import re
4
3
  from enum import Enum
5
4
  from typing import Any, Dict, List, Literal, Optional, Union
6
5
 
@@ -9,6 +8,7 @@ from pydantic import BaseModel, Field, model_validator
9
8
  from letta.constants import LETTA_TOOL_EXECUTION_DIR
10
9
  from letta.schemas.agent import AgentState
11
10
  from letta.schemas.letta_base import LettaBase, OrmMetadataBase
11
+ from letta.schemas.pip_requirement import PipRequirement
12
12
  from letta.settings import tool_settings
13
13
 
14
14
 
@@ -27,24 +27,6 @@ class SandboxRunResult(BaseModel):
27
27
  sandbox_config_fingerprint: str = Field(None, description="The fingerprint of the config for the sandbox")
28
28
 
29
29
 
30
- class PipRequirement(BaseModel):
31
- name: str = Field(..., min_length=1, description="Name of the pip package.")
32
- version: Optional[str] = Field(None, description="Optional version of the package, following semantic versioning.")
33
-
34
- @classmethod
35
- def validate_version(cls, version: Optional[str]) -> Optional[str]:
36
- if version is None:
37
- return None
38
- semver_pattern = re.compile(r"^\d+(\.\d+){0,2}(-[a-zA-Z0-9.]+)?$")
39
- if not semver_pattern.match(version):
40
- raise ValueError(f"Invalid version format: {version}. Must follow semantic versioning (e.g., 1.2.3, 2.0, 1.5.0-alpha).")
41
- return version
42
-
43
- def __init__(self, **data):
44
- super().__init__(**data)
45
- self.version = self.validate_version(self.version)
46
-
47
-
48
30
  class LocalSandboxConfig(BaseModel):
49
31
  sandbox_dir: Optional[str] = Field(None, description="Directory for the sandbox environment.")
50
32
  use_venv: bool = Field(False, description="Whether or not to use the venv, or run directly in the same run loop.")
letta/schemas/tool.py CHANGED
@@ -24,6 +24,7 @@ from letta.functions.schema_generator import (
24
24
  from letta.log import get_logger
25
25
  from letta.orm.enums import ToolType
26
26
  from letta.schemas.letta_base import LettaBase
27
+ from letta.schemas.pip_requirement import PipRequirement
27
28
 
28
29
  logger = get_logger(__name__)
29
30
 
@@ -60,6 +61,7 @@ class Tool(BaseTool):
60
61
 
61
62
  # tool configuration
62
63
  return_char_limit: int = Field(FUNCTION_RETURN_CHAR_LIMIT, description="The maximum number of characters in the response.")
64
+ pip_requirements: Optional[List[PipRequirement]] = Field(None, description="Optional list of pip packages required by this tool.")
63
65
 
64
66
  # metadata fields
65
67
  created_by_id: Optional[str] = Field(None, description="The id of the user that made this Tool.")
@@ -145,6 +147,7 @@ class ToolCreate(LettaBase):
145
147
  )
146
148
  args_json_schema: Optional[Dict] = Field(None, description="The args JSON schema of the function.")
147
149
  return_char_limit: int = Field(FUNCTION_RETURN_CHAR_LIMIT, description="The maximum number of characters in the response.")
150
+ pip_requirements: Optional[List[PipRequirement]] = Field(None, description="Optional list of pip packages required by this tool.")
148
151
 
149
152
  # TODO should we put the HTTP / API fetch inside from_mcp?
150
153
  # async def from_mcp(cls, mcp_server: str, mcp_tool_name: str) -> "ToolCreate":
@@ -253,6 +256,7 @@ class ToolUpdate(LettaBase):
253
256
  )
254
257
  args_json_schema: Optional[Dict] = Field(None, description="The args JSON schema of the function.")
255
258
  return_char_limit: Optional[int] = Field(None, description="The maximum number of characters in the response.")
259
+ pip_requirements: Optional[List[PipRequirement]] = Field(None, description="Optional list of pip packages required by this tool.")
256
260
 
257
261
  class Config:
258
262
  extra = "ignore" # Allows extra fields without validation errors
@@ -269,3 +273,4 @@ class ToolRunFromSource(LettaBase):
269
273
  json_schema: Optional[Dict] = Field(
270
274
  None, description="The JSON schema of the function (auto-generated from source_code if not provided)"
271
275
  )
276
+ pip_requirements: Optional[List[PipRequirement]] = Field(None, description="Optional list of pip packages required by this tool.")
@@ -32,9 +32,14 @@ class PydanticJSONParser(JSONParser):
32
32
  return {}
33
33
  try:
34
34
  return from_json(input_str, allow_partial="trailing-strings" if not self.strict else False)
35
- except ValueError as e:
36
- logger.error(f"Failed to parse JSON: {e}")
37
- raise
35
+ except Exception as e:
36
+ logger.warning(f"PydanticJSONParser failed: {e} | input_str={input_str!r}, falling back to OptimisticJSONParser")
37
+ try:
38
+ fallback_parser = OptimisticJSONParser(strict=self.strict)
39
+ return fallback_parser.parse(input_str)
40
+ except Exception as fallback_e:
41
+ logger.error(f"Both parsers failed. Pydantic: {e}, Optimistic: {fallback_e} | input_str={input_str!r}")
42
+ raise fallback_e
38
43
 
39
44
 
40
45
  class OptimisticJSONParser(JSONParser):
@@ -219,3 +224,34 @@ class OptimisticJSONParser(JSONParser):
219
224
  if input_str.startswith("n"):
220
225
  return None, input_str[4:]
221
226
  raise decode_error
227
+
228
+
229
+ # TODO: Keeping this around for posterity
230
+ # def main():
231
+ # test_string = '{"inner_thoughts":}'
232
+ #
233
+ # print(f"Testing string: {test_string!r}")
234
+ # print("=" * 50)
235
+ #
236
+ # print("OptimisticJSONParser (strict=False):")
237
+ # try:
238
+ # optimistic_parser = OptimisticJSONParser(strict=False)
239
+ # result = optimistic_parser.parse(test_string)
240
+ # print(f" Result: {result}")
241
+ # print(f" Remaining: {optimistic_parser.last_parse_reminding!r}")
242
+ # except Exception as e:
243
+ # print(f" Error: {e}")
244
+ #
245
+ # print()
246
+ #
247
+ # print("PydanticJSONParser (strict=False):")
248
+ # try:
249
+ # pydantic_parser = PydanticJSONParser(strict=False)
250
+ # result = pydantic_parser.parse(test_string)
251
+ # print(f" Result: {result}")
252
+ # except Exception as e:
253
+ # print(f" Error: {e}")
254
+ #
255
+ #
256
+ # if __name__ == "__main__":
257
+ # main()
@@ -504,7 +504,9 @@ async def add_mcp_server_to_config(
504
504
  if tool_settings.mcp_disable_stdio: # protected server
505
505
  raise HTTPException(status_code=400, detail="StdioServerConfig is not supported")
506
506
  elif isinstance(request, SSEServerConfig):
507
- mapped_request = MCPServer(server_name=request.server_name, server_type=request.type, server_url=request.server_url)
507
+ mapped_request = MCPServer(
508
+ server_name=request.server_name, server_type=request.type, server_url=request.server_url, token=request.resolve_token()
509
+ )
508
510
  # TODO: add HTTP streaming
509
511
  mcp_server = await server.mcp_manager.create_or_update_mcp_server(mapped_request, actor=actor)
510
512
 
@@ -1,9 +1,8 @@
1
- from typing import TYPE_CHECKING, Optional
1
+ from typing import TYPE_CHECKING, Any, Dict, Optional
2
2
 
3
3
  import openai
4
4
  from fastapi import APIRouter, Body, Depends, Header
5
5
  from fastapi.responses import StreamingResponse
6
- from openai.types.chat.completion_create_params import CompletionCreateParams
7
6
 
8
7
  from letta.agents.voice_agent import VoiceAgent
9
8
  from letta.log import get_logger
@@ -32,7 +31,7 @@ logger = get_logger(__name__)
32
31
  )
33
32
  async def create_voice_chat_completions(
34
33
  agent_id: str,
35
- completion_request: CompletionCreateParams = Body(...),
34
+ completion_request: Dict[str, Any] = Body(...), # The validation is soft in case providers like VAPI send extra params
36
35
  server: "SyncServer" = Depends(get_letta_server),
37
36
  user_id: Optional[str] = Header(None, alias="user_id"),
38
37
  ):
@@ -88,7 +88,7 @@ async def sse_async_generator(
88
88
  metric_attributes = get_ctx_attributes()
89
89
  if llm_config:
90
90
  metric_attributes["model.name"] = llm_config.model
91
- MetricRegistry().ttft_ms_histogram.record(ns_to_ms(ttft_ns), metric_attributes)
91
+ MetricRegistry().ttft_ms_histogram.record(ns_to_ms(ttft_ns), metric_attributes)
92
92
  first_chunk = False
93
93
 
94
94
  # yield f"data: {json.dumps(chunk)}\n\n"
letta/server/server.py CHANGED
@@ -48,6 +48,7 @@ from letta.schemas.job import Job, JobUpdate
48
48
  from letta.schemas.letta_message import LegacyLettaMessage, LettaMessage, MessageType, ToolReturnMessage
49
49
  from letta.schemas.letta_message_content import TextContent
50
50
  from letta.schemas.letta_response import LettaResponse
51
+ from letta.schemas.letta_stop_reason import LettaStopReason, StopReasonType
51
52
  from letta.schemas.llm_config import LLMConfig
52
53
  from letta.schemas.memory import ArchivalMemorySummary, Memory, RecallMemorySummary
53
54
  from letta.schemas.message import Message, MessageCreate, MessageUpdate
@@ -2359,7 +2360,11 @@ class SyncServer(Server):
2359
2360
  # If we want to convert these to Message, we can use the attached IDs
2360
2361
  # NOTE: we will need to de-duplicate the Messsage IDs though (since Assistant->Inner+Func_Call)
2361
2362
  # TODO: eventually update the interface to use `Message` and `MessageChunk` (new) inside the deque instead
2362
- return LettaResponse(messages=filtered_stream, usage=usage)
2363
+ return LettaResponse(
2364
+ messages=filtered_stream,
2365
+ stop_reason=LettaStopReason(stop_reason=StopReasonType.end_turn.value),
2366
+ usage=usage,
2367
+ )
2363
2368
 
2364
2369
  except HTTPException:
2365
2370
  raise
@@ -2461,4 +2466,8 @@ class SyncServer(Server):
2461
2466
  # If we want to convert these to Message, we can use the attached IDs
2462
2467
  # NOTE: we will need to de-duplicate the Messsage IDs though (since Assistant->Inner+Func_Call)
2463
2468
  # TODO: eventually update the interface to use `Message` and `MessageChunk` (new) inside the deque instead
2464
- return LettaResponse(messages=filtered_stream, usage=usage)
2469
+ return LettaResponse(
2470
+ messages=filtered_stream,
2471
+ stop_reason=LettaStopReason(stop_reason=StopReasonType.end_turn.value),
2472
+ usage=usage,
2473
+ )
@@ -15,7 +15,6 @@ from letta.constants import (
15
15
  BASE_TOOLS,
16
16
  BASE_VOICE_SLEEPTIME_CHAT_TOOLS,
17
17
  BASE_VOICE_SLEEPTIME_TOOLS,
18
- DATA_SOURCE_ATTACH_ALERT,
19
18
  FILES_TOOLS,
20
19
  MULTI_AGENT_TOOLS,
21
20
  )
@@ -1419,7 +1418,7 @@ class AgentManager:
1419
1418
  system_prompt=agent_state.system,
1420
1419
  in_context_memory=agent_state.memory,
1421
1420
  in_context_memory_last_edit=memory_edit_timestamp,
1422
- previous_message_count=num_messages,
1421
+ previous_message_count=num_messages - len(agent_state.message_ids),
1423
1422
  archival_memory_size=num_archival_memories,
1424
1423
  )
1425
1424
 
@@ -1493,7 +1492,7 @@ class AgentManager:
1493
1492
  system_prompt=agent_state.system,
1494
1493
  in_context_memory=agent_state.memory,
1495
1494
  in_context_memory_last_edit=memory_edit_timestamp,
1496
- previous_message_count=num_messages,
1495
+ previous_message_count=num_messages - len(agent_state.message_ids),
1497
1496
  archival_memory_size=num_archival_memories,
1498
1497
  tool_rules_solver=tool_rules_solver,
1499
1498
  )
@@ -1575,10 +1574,11 @@ class AgentManager:
1575
1574
  self, agent_id: str, actor: PydanticUser, add_default_initial_messages: bool = False
1576
1575
  ) -> PydanticAgentState:
1577
1576
  """
1578
- Removes all in-context messages for the specified agent by:
1579
- 1) Clearing the agent.messages relationship (which cascades delete-orphans).
1580
- 2) Resetting the message_ids list to empty.
1581
- 3) Committing the transaction.
1577
+ Removes all in-context messages for the specified agent except the original system message by:
1578
+ 1) Preserving the first message ID (original system message).
1579
+ 2) Deleting all other messages for the agent.
1580
+ 3) Updating the agent's message_ids to only contain the system message.
1581
+ 4) Optionally adding default initial messages after the system message.
1582
1582
 
1583
1583
  This action is destructive and cannot be undone once committed.
1584
1584
 
@@ -1588,35 +1588,49 @@ class AgentManager:
1588
1588
  actor (PydanticUser): The user performing this action.
1589
1589
 
1590
1590
  Returns:
1591
- PydanticAgentState: The updated agent state with no linked messages.
1591
+ PydanticAgentState: The updated agent state with only the original system message preserved.
1592
1592
  """
1593
1593
  async with db_registry.async_session() as session:
1594
1594
  # Retrieve the existing agent (will raise NoResultFound if invalid)
1595
1595
  agent = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
1596
1596
 
1597
- # Also clear out the message_ids field to keep in-context memory consistent
1598
- agent.message_ids = []
1597
+ # Ensure agent has message_ids with at least one message
1598
+ if not agent.message_ids or len(agent.message_ids) == 0:
1599
+ logger.error(
1600
+ f"Agent {agent_id} has no message_ids. Agent details: "
1601
+ f"name={agent.name}, created_at={agent.created_at}, "
1602
+ f"message_ids={agent.message_ids}, organization_id={agent.organization_id}"
1603
+ )
1604
+ raise ValueError(f"Agent {agent_id} has no message_ids - cannot preserve system message")
1599
1605
 
1600
- # Commit the update
1601
- await agent.update_async(db_session=session, actor=actor)
1606
+ # Get the system message ID (first message)
1607
+ system_message_id = agent.message_ids[0]
1602
1608
 
1603
- agent_state = await agent.to_pydantic_async()
1609
+ # Delete all messages for the agent except the system message
1610
+ await self.message_manager.delete_all_messages_for_agent_async(agent_id=agent_id, actor=actor, exclude_ids=[system_message_id])
1604
1611
 
1605
- await self.message_manager.delete_all_messages_for_agent_async(agent_id=agent_id, actor=actor)
1612
+ # Update agent to only keep the system message
1613
+ agent.message_ids = [system_message_id]
1614
+ await agent.update_async(db_session=session, actor=actor)
1615
+ agent_state = await agent.to_pydantic_async()
1606
1616
 
1617
+ # Optionally add default initial messages after the system message
1607
1618
  if add_default_initial_messages:
1608
- return await self.append_initial_message_sequence_to_in_context_messages_async(actor, agent_state)
1609
- else:
1610
- # We still want to always have a system message
1611
1619
  init_messages = initialize_message_sequence(
1612
1620
  agent_state=agent_state, memory_edit_timestamp=get_utc_time(), include_initial_boot_message=True
1613
1621
  )
1614
- system_message = PydanticMessage.dict_to_message(
1615
- agent_id=agent_state.id,
1616
- model=agent_state.llm_config.model,
1617
- openai_message_dict=init_messages[0],
1618
- )
1619
- return await self.append_to_in_context_messages_async([system_message], agent_id=agent_state.id, actor=actor)
1622
+ # Skip index 0 (system message) since we preserved the original
1623
+ non_system_messages = [
1624
+ PydanticMessage.dict_to_message(
1625
+ agent_id=agent_state.id,
1626
+ model=agent_state.llm_config.model,
1627
+ openai_message_dict=msg,
1628
+ )
1629
+ for msg in init_messages[1:]
1630
+ ]
1631
+ return await self.append_to_in_context_messages_async(non_system_messages, agent_id=agent_state.id, actor=actor)
1632
+ else:
1633
+ return agent_state
1620
1634
 
1621
1635
  @trace_method
1622
1636
  @enforce_types
@@ -1717,13 +1731,7 @@ class AgentManager:
1717
1731
  await agent.update_async(session, actor=actor)
1718
1732
 
1719
1733
  # Force rebuild of system prompt so that the agent is updated with passage count
1720
- # and recent passages and add system message alert to agent
1721
1734
  pydantic_agent = await self.rebuild_system_prompt_async(agent_id=agent_id, actor=actor, force=True)
1722
- await self.append_system_message_async(
1723
- agent_id=agent_id,
1724
- content=DATA_SOURCE_ATTACH_ALERT,
1725
- actor=actor,
1726
- )
1727
1735
 
1728
1736
  return pydantic_agent
1729
1737
 
@@ -2,7 +2,7 @@ import os
2
2
  import platform
3
3
  import subprocess
4
4
  import venv
5
- from typing import Dict, Optional
5
+ from typing import TYPE_CHECKING, Dict, Optional
6
6
 
7
7
  from datamodel_code_generator import DataModelType, PythonVersion
8
8
  from datamodel_code_generator.model import get_data_model_types
@@ -11,6 +11,9 @@ from datamodel_code_generator.parser.jsonschema import JsonSchemaParser
11
11
  from letta.log import get_logger
12
12
  from letta.schemas.sandbox_config import LocalSandboxConfig
13
13
 
14
+ if TYPE_CHECKING:
15
+ from letta.schemas.tool import Tool
16
+
14
17
  logger = get_logger(__name__)
15
18
 
16
19
 
@@ -85,14 +88,12 @@ def install_pip_requirements_for_sandbox(
85
88
  upgrade: bool = True,
86
89
  user_install_if_no_venv: bool = False,
87
90
  env: Optional[Dict[str, str]] = None,
91
+ tool: Optional["Tool"] = None,
88
92
  ):
89
93
  """
90
94
  Installs the specified pip requirements inside the correct environment (venv or system).
95
+ Installs both sandbox-level and tool-specific pip requirements.
91
96
  """
92
- if not local_configs.pip_requirements:
93
- logger.debug("No pip requirements specified; skipping installation.")
94
- return
95
-
96
97
  sandbox_dir = os.path.expanduser(local_configs.sandbox_dir) # Expand tilde
97
98
  local_configs.sandbox_dir = sandbox_dir # Update the object to store the absolute path
98
99
 
@@ -102,19 +103,48 @@ def install_pip_requirements_for_sandbox(
102
103
  if local_configs.use_venv:
103
104
  ensure_pip_is_up_to_date(python_exec, env=env)
104
105
 
105
- # Construct package list
106
- packages = [f"{req.name}=={req.version}" if req.version else req.name for req in local_configs.pip_requirements]
106
+ # Collect all pip requirements
107
+ all_packages = []
108
+
109
+ # Add sandbox-level pip requirements
110
+ if local_configs.pip_requirements:
111
+ packages = [f"{req.name}=={req.version}" if req.version else req.name for req in local_configs.pip_requirements]
112
+ all_packages.extend(packages)
113
+ logger.debug(f"Added sandbox pip requirements: {packages}")
114
+
115
+ # Add tool-specific pip requirements
116
+ if tool and tool.pip_requirements:
117
+ tool_packages = [str(req) for req in tool.pip_requirements]
118
+ all_packages.extend(tool_packages)
119
+ logger.debug(f"Added tool pip requirements for {tool.name}: {tool_packages}")
120
+
121
+ if not all_packages:
122
+ logger.debug("No pip requirements specified; skipping installation.")
123
+ return
107
124
 
108
125
  # Construct pip install command
109
126
  pip_cmd = [python_exec, "-m", "pip", "install"]
110
127
  if upgrade:
111
128
  pip_cmd.append("--upgrade")
112
- pip_cmd += packages
129
+ pip_cmd += all_packages
113
130
 
114
131
  if user_install_if_no_venv and not local_configs.use_venv:
115
132
  pip_cmd.append("--user")
116
133
 
117
- run_subprocess(pip_cmd, env=env, fail_msg=f"Failed to install packages: {', '.join(packages)}")
134
+ # Enhanced error message for better debugging
135
+ sandbox_packages = [f"{req.name}=={req.version}" if req.version else req.name for req in (local_configs.pip_requirements or [])]
136
+ tool_packages = [str(req) for req in (tool.pip_requirements if tool and tool.pip_requirements else [])]
137
+
138
+ error_details = []
139
+ if sandbox_packages:
140
+ error_details.append(f"sandbox requirements: {', '.join(sandbox_packages)}")
141
+ if tool_packages:
142
+ error_details.append(f"tool requirements: {', '.join(tool_packages)}")
143
+
144
+ context = f" ({'; '.join(error_details)})" if error_details else ""
145
+ fail_msg = f"Failed to install pip packages{context}. This may be due to package version incompatibility. Consider updating package versions or removing version constraints."
146
+
147
+ run_subprocess(pip_cmd, env=env, fail_msg=fail_msg)
118
148
 
119
149
 
120
150
  def create_venv_for_local_sandbox(sandbox_dir_path: str, venv_path: str, env: Dict[str, str], force_recreate: bool):