letta-nightly 0.8.3.dev20250612104349__py3-none-any.whl → 0.8.4.dev20250614104137__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 (57) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +11 -1
  3. letta/agents/base_agent.py +11 -4
  4. letta/agents/ephemeral_summary_agent.py +3 -2
  5. letta/agents/letta_agent.py +109 -78
  6. letta/agents/letta_agent_batch.py +4 -3
  7. letta/agents/voice_agent.py +3 -3
  8. letta/agents/voice_sleeptime_agent.py +3 -2
  9. letta/client/client.py +6 -3
  10. letta/constants.py +6 -0
  11. letta/data_sources/connectors.py +3 -5
  12. letta/functions/async_composio_toolset.py +4 -1
  13. letta/functions/function_sets/files.py +4 -3
  14. letta/functions/schema_generator.py +5 -2
  15. letta/groups/sleeptime_multi_agent_v2.py +4 -3
  16. letta/helpers/converters.py +7 -1
  17. letta/helpers/message_helper.py +31 -11
  18. letta/helpers/tool_rule_solver.py +69 -4
  19. letta/interfaces/anthropic_streaming_interface.py +8 -1
  20. letta/interfaces/openai_streaming_interface.py +4 -1
  21. letta/llm_api/anthropic_client.py +4 -4
  22. letta/llm_api/openai_client.py +56 -11
  23. letta/local_llm/utils.py +3 -20
  24. letta/orm/sqlalchemy_base.py +7 -1
  25. letta/otel/metric_registry.py +26 -0
  26. letta/otel/metrics.py +78 -14
  27. letta/schemas/letta_message_content.py +64 -3
  28. letta/schemas/letta_request.py +5 -1
  29. letta/schemas/message.py +61 -14
  30. letta/schemas/openai/chat_completion_request.py +1 -1
  31. letta/schemas/providers.py +41 -14
  32. letta/schemas/tool_rule.py +67 -0
  33. letta/schemas/user.py +2 -2
  34. letta/server/rest_api/routers/v1/agents.py +22 -12
  35. letta/server/rest_api/routers/v1/sources.py +13 -25
  36. letta/server/server.py +10 -5
  37. letta/services/agent_manager.py +5 -1
  38. letta/services/file_manager.py +219 -0
  39. letta/services/file_processor/chunker/line_chunker.py +119 -14
  40. letta/services/file_processor/file_processor.py +8 -8
  41. letta/services/file_processor/file_types.py +303 -0
  42. letta/services/file_processor/parser/mistral_parser.py +2 -11
  43. letta/services/helpers/agent_manager_helper.py +6 -0
  44. letta/services/message_manager.py +32 -0
  45. letta/services/organization_manager.py +4 -6
  46. letta/services/passage_manager.py +1 -0
  47. letta/services/source_manager.py +0 -208
  48. letta/services/tool_executor/composio_tool_executor.py +5 -1
  49. letta/services/tool_executor/files_tool_executor.py +291 -15
  50. letta/services/user_manager.py +8 -8
  51. letta/system.py +3 -1
  52. letta/utils.py +7 -13
  53. {letta_nightly-0.8.3.dev20250612104349.dist-info → letta_nightly-0.8.4.dev20250614104137.dist-info}/METADATA +2 -2
  54. {letta_nightly-0.8.3.dev20250612104349.dist-info → letta_nightly-0.8.4.dev20250614104137.dist-info}/RECORD +57 -55
  55. {letta_nightly-0.8.3.dev20250612104349.dist-info → letta_nightly-0.8.4.dev20250614104137.dist-info}/LICENSE +0 -0
  56. {letta_nightly-0.8.3.dev20250612104349.dist-info → letta_nightly-0.8.4.dev20250614104137.dist-info}/WHEEL +0 -0
  57. {letta_nightly-0.8.3.dev20250612104349.dist-info → letta_nightly-0.8.4.dev20250614104137.dist-info}/entry_points.txt +0 -0
letta/otel/metrics.py CHANGED
@@ -1,3 +1,7 @@
1
+ import re
2
+ import time
3
+ from typing import List
4
+
1
5
  from fastapi import FastAPI, Request
2
6
  from opentelemetry import metrics
3
7
  from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
@@ -5,8 +9,9 @@ from opentelemetry.metrics import NoOpMeter
5
9
  from opentelemetry.sdk.metrics import MeterProvider
6
10
  from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
7
11
 
12
+ from letta.helpers.datetime_helpers import ns_to_ms
8
13
  from letta.log import get_logger
9
- from letta.otel.context import add_ctx_attribute
14
+ from letta.otel.context import add_ctx_attribute, get_ctx_attributes
10
15
  from letta.otel.resource import get_resource, is_pytest_environment
11
16
 
12
17
  logger = get_logger(__name__)
@@ -14,26 +19,85 @@ logger = get_logger(__name__)
14
19
  _meter: metrics.Meter = NoOpMeter("noop")
15
20
  _is_metrics_initialized: bool = False
16
21
 
22
+ # Endpoints to include in endpoint metrics tracking (opt-in) vs tracing.py opt-out
23
+ _included_v1_endpoints_regex: List[str] = [
24
+ "^POST /v1/agents/(?P<agent_id>[^/]+)/messages$",
25
+ "^POST /v1/agents/(?P<agent_id>[^/]+)/messages/stream$",
26
+ "^POST /v1/agents/(?P<agent_id>[^/]+)/messages/async$",
27
+ ]
28
+
29
+ # Header attributes to set context with
30
+ header_attributes = {
31
+ "x-organization-id": "organization.id",
32
+ "x-project-id": "project.id",
33
+ "x-base-template-id": "base_template.id",
34
+ "x-template-id": "template.id",
35
+ "x-agent-id": "agent.id",
36
+ }
37
+
17
38
 
18
39
  async def _otel_metric_middleware(request: Request, call_next):
19
40
  if not _is_metrics_initialized:
20
41
  return await call_next(request)
21
42
 
22
- header_attributes = {
23
- "x-organization-id": "organization.id",
24
- "x-project-id": "project.id",
25
- "x-base-template-id": "base_template.id",
26
- "x-template-id": "template.id",
27
- "x-agent-id": "agent.id",
28
- }
29
- try:
30
- for header_key, otel_key in header_attributes.items():
31
- header_value = request.headers.get(header_key)
32
- if header_value:
33
- add_ctx_attribute(otel_key, header_value)
43
+ for header_key, otel_key in header_attributes.items():
44
+ header_value = request.headers.get(header_key)
45
+ if header_value:
46
+ add_ctx_attribute(otel_key, header_value)
47
+
48
+ # Opt-in check for latency / error tracking
49
+ endpoint_path = f"{request.method} {request.url.path}"
50
+ should_track_endpoint_metrics = any(re.match(regex, endpoint_path) for regex in _included_v1_endpoints_regex)
51
+
52
+ if not should_track_endpoint_metrics:
34
53
  return await call_next(request)
35
- except Exception:
54
+
55
+ # --- Opt-in endpoint metrics ---
56
+ start_perf_counter_ns = time.perf_counter_ns()
57
+ response = None
58
+ status_code = 500 # reasonable default
59
+
60
+ try:
61
+ response = await call_next(request)
62
+ status_code = response.status_code
63
+ return response
64
+ except Exception as e:
65
+ # Determine status code from exception
66
+ status_code = getattr(e, "status_code", 500)
36
67
  raise
68
+ finally:
69
+ end_to_end_ms = ns_to_ms(time.perf_counter_ns() - start_perf_counter_ns)
70
+ _record_endpoint_metrics(
71
+ request=request,
72
+ latency_ms=end_to_end_ms,
73
+ status_code=status_code,
74
+ )
75
+
76
+
77
+ def _record_endpoint_metrics(
78
+ request: Request,
79
+ latency_ms: float,
80
+ status_code: int,
81
+ ):
82
+ """Record endpoint latency and request count metrics."""
83
+ try:
84
+ # Get the route pattern for better endpoint naming
85
+ route = request.scope.get("route")
86
+ endpoint_name = route.path if route and hasattr(route, "path") else "unknown"
87
+
88
+ attrs = {
89
+ "endpoint_path": endpoint_name,
90
+ "method": request.method,
91
+ "status_code": status_code,
92
+ **get_ctx_attributes(),
93
+ }
94
+ from letta.otel.metric_registry import MetricRegistry
95
+
96
+ MetricRegistry().endpoint_e2e_ms_histogram.record(latency_ms, attributes=attrs)
97
+ MetricRegistry().endpoint_request_counter.add(1, attributes=attrs)
98
+
99
+ except Exception as e:
100
+ logger.warning(f"Failed to record endpoint metrics: {e}")
37
101
 
38
102
 
39
103
  def setup_metrics(
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
6
6
 
7
7
  class MessageContentType(str, Enum):
8
8
  text = "text"
9
+ image = "image"
9
10
  tool_call = "tool_call"
10
11
  tool_return = "tool_return"
11
12
  reasoning = "reasoning"
@@ -18,7 +19,7 @@ class MessageContent(BaseModel):
18
19
 
19
20
 
20
21
  # -------------------------------
21
- # User Content Types
22
+ # Text Content
22
23
  # -------------------------------
23
24
 
24
25
 
@@ -27,8 +28,62 @@ class TextContent(MessageContent):
27
28
  text: str = Field(..., description="The text content of the message.")
28
29
 
29
30
 
31
+ # -------------------------------
32
+ # Image Content
33
+ # -------------------------------
34
+
35
+
36
+ class ImageSourceType(str, Enum):
37
+ url = "url"
38
+ base64 = "base64"
39
+ letta = "letta"
40
+
41
+
42
+ class ImageSource(BaseModel):
43
+ type: ImageSourceType = Field(..., description="The source type for the image.")
44
+
45
+
46
+ class UrlImage(ImageSource):
47
+ type: Literal[ImageSourceType.url] = Field(ImageSourceType.url, description="The source type for the image.")
48
+ url: str = Field(..., description="The URL of the image.")
49
+
50
+
51
+ class Base64Image(ImageSource):
52
+ type: Literal[ImageSourceType.base64] = Field(ImageSourceType.base64, description="The source type for the image.")
53
+ media_type: str = Field(..., description="The media type for the image.")
54
+ data: str = Field(..., description="The base64 encoded image data.")
55
+ detail: Optional[str] = Field(
56
+ None,
57
+ description="What level of detail to use when processing and understanding the image (low, high, or auto to let the model decide)",
58
+ )
59
+
60
+
61
+ class LettaImage(ImageSource):
62
+ type: Literal[ImageSourceType.letta] = Field(ImageSourceType.letta, description="The source type for the image.")
63
+ file_id: str = Field(..., description="The unique identifier of the image file persisted in storage.")
64
+ media_type: Optional[str] = Field(None, description="The media type for the image.")
65
+ data: Optional[str] = Field(None, description="The base64 encoded image data.")
66
+ detail: Optional[str] = Field(
67
+ None,
68
+ description="What level of detail to use when processing and understanding the image (low, high, or auto to let the model decide)",
69
+ )
70
+
71
+
72
+ ImageSourceUnion = Annotated[Union[UrlImage, Base64Image, LettaImage], Field(discriminator="type")]
73
+
74
+
75
+ class ImageContent(MessageContent):
76
+ type: Literal[MessageContentType.image] = Field(MessageContentType.image, description="The type of the message.")
77
+ source: ImageSourceUnion = Field(..., description="The source of the image.")
78
+
79
+
80
+ # -------------------------------
81
+ # User Content Types
82
+ # -------------------------------
83
+
84
+
30
85
  LettaUserMessageContentUnion = Annotated[
31
- Union[TextContent],
86
+ Union[TextContent, ImageContent],
32
87
  Field(discriminator="type"),
33
88
  ]
34
89
 
@@ -37,11 +92,13 @@ def create_letta_user_message_content_union_schema():
37
92
  return {
38
93
  "oneOf": [
39
94
  {"$ref": "#/components/schemas/TextContent"},
95
+ {"$ref": "#/components/schemas/ImageContent"},
40
96
  ],
41
97
  "discriminator": {
42
98
  "propertyName": "type",
43
99
  "mapping": {
44
100
  "text": "#/components/schemas/TextContent",
101
+ "image": "#/components/schemas/ImageContent",
45
102
  },
46
103
  },
47
104
  }
@@ -150,7 +207,9 @@ class OmittedReasoningContent(MessageContent):
150
207
 
151
208
 
152
209
  LettaMessageContentUnion = Annotated[
153
- Union[TextContent, ToolCallContent, ToolReturnContent, ReasoningContent, RedactedReasoningContent, OmittedReasoningContent],
210
+ Union[
211
+ TextContent, ImageContent, ToolCallContent, ToolReturnContent, ReasoningContent, RedactedReasoningContent, OmittedReasoningContent
212
+ ],
154
213
  Field(discriminator="type"),
155
214
  ]
156
215
 
@@ -159,6 +218,7 @@ def create_letta_message_content_union_schema():
159
218
  return {
160
219
  "oneOf": [
161
220
  {"$ref": "#/components/schemas/TextContent"},
221
+ {"$ref": "#/components/schemas/ImageContent"},
162
222
  {"$ref": "#/components/schemas/ToolCallContent"},
163
223
  {"$ref": "#/components/schemas/ToolReturnContent"},
164
224
  {"$ref": "#/components/schemas/ReasoningContent"},
@@ -169,6 +229,7 @@ def create_letta_message_content_union_schema():
169
229
  "propertyName": "type",
170
230
  "mapping": {
171
231
  "text": "#/components/schemas/TextContent",
232
+ "image": "#/components/schemas/ImageContent",
172
233
  "tool_call": "#/components/schemas/ToolCallContent",
173
234
  "tool_return": "#/components/schemas/ToolCallContent",
174
235
  "reasoning": "#/components/schemas/ReasoningContent",
@@ -2,13 +2,17 @@ from typing import List, Optional
2
2
 
3
3
  from pydantic import BaseModel, Field, HttpUrl
4
4
 
5
- from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
5
+ from letta.constants import DEFAULT_MAX_STEPS, DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
6
6
  from letta.schemas.letta_message import MessageType
7
7
  from letta.schemas.message import MessageCreate
8
8
 
9
9
 
10
10
  class LettaRequest(BaseModel):
11
11
  messages: List[MessageCreate] = Field(..., description="The messages to be sent to the agent.")
12
+ max_steps: int = Field(
13
+ default=DEFAULT_MAX_STEPS,
14
+ description="Maximum number of steps the agent should take to process the request.",
15
+ )
12
16
  use_assistant_message: bool = Field(
13
17
  default=True,
14
18
  description="Whether the server should parse specific tool call arguments (default `send_message`) as `AssistantMessage` objects.",
letta/schemas/message.py CHANGED
@@ -31,6 +31,7 @@ from letta.schemas.letta_message import (
31
31
  UserMessage,
32
32
  )
33
33
  from letta.schemas.letta_message_content import (
34
+ ImageContent,
34
35
  LettaMessageContentUnion,
35
36
  OmittedReasoningContent,
36
37
  ReasoningContent,
@@ -415,15 +416,17 @@ class Message(BaseMessage):
415
416
  # This is type UserMessage
416
417
  if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
417
418
  text_content = self.content[0].text
419
+ elif self.content:
420
+ text_content = self.content
418
421
  else:
419
422
  raise ValueError(f"Invalid user message (no text object on message): {self.content}")
420
423
 
421
- message_str = unpack_message(text_content)
424
+ message = unpack_message(text_content)
422
425
  messages.append(
423
426
  UserMessage(
424
427
  id=self.id,
425
428
  date=self.created_at,
426
- content=message_str or text_content,
429
+ content=message,
427
430
  name=self.name,
428
431
  otid=self.otid,
429
432
  sender_id=self.sender_id,
@@ -658,13 +661,14 @@ class Message(BaseMessage):
658
661
  text_content = self.content[0].text
659
662
  elif self.content and len(self.content) == 1 and isinstance(self.content[0], ToolReturnContent):
660
663
  text_content = self.content[0].content
664
+ elif self.content and len(self.content) == 1 and isinstance(self.content[0], ImageContent):
665
+ text_content = "[Image Here]"
661
666
  # Otherwise, check if we have TextContent and multiple other parts
662
667
  elif self.content and len(self.content) > 1:
663
668
  text = [content for content in self.content if isinstance(content, TextContent)]
664
- if len(text) > 1:
665
- assert len(text) == 1, f"multiple text content parts found in a single message: {self.content}"
666
- text_content = text[0].text
667
- parse_content_parts = True
669
+ assert len(text) == 1, f"multiple text content parts found in a single message: {self.content}"
670
+ text_content = text[0].text
671
+ parse_content_parts = True
668
672
  else:
669
673
  text_content = None
670
674
 
@@ -778,11 +782,35 @@ class Message(BaseMessage):
778
782
  }
779
783
 
780
784
  elif self.role == "user":
781
- assert all([v is not None for v in [text_content, self.role]]), vars(self)
782
- anthropic_message = {
783
- "content": text_content,
784
- "role": self.role,
785
- }
785
+ # special case for text-only message
786
+ if text_content is not None:
787
+ anthropic_message = {
788
+ "content": text_content,
789
+ "role": self.role,
790
+ }
791
+ else:
792
+ content_parts = []
793
+ for content in self.content:
794
+ if isinstance(content, TextContent):
795
+ content_parts.append({"type": "text", "text": content.text})
796
+ elif isinstance(content, ImageContent):
797
+ content_parts.append(
798
+ {
799
+ "type": "image",
800
+ "source": {
801
+ "type": "base64",
802
+ "data": content.source.data,
803
+ "media_type": content.source.media_type,
804
+ },
805
+ }
806
+ )
807
+ else:
808
+ raise ValueError(f"Unsupported content type: {content.type}")
809
+
810
+ anthropic_message = {
811
+ "content": content_parts,
812
+ "role": self.role,
813
+ }
786
814
 
787
815
  elif self.role == "assistant":
788
816
  assert self.tool_calls is not None or text_content is not None
@@ -887,10 +915,27 @@ class Message(BaseMessage):
887
915
  }
888
916
 
889
917
  elif self.role == "user":
890
- assert all([v is not None for v in [text_content, self.role]]), vars(self)
918
+ assert self.content, vars(self)
919
+
920
+ content_parts = []
921
+ for content in self.content:
922
+ if isinstance(content, TextContent):
923
+ content_parts.append({"text": content.text})
924
+ elif isinstance(content, ImageContent):
925
+ content_parts.append(
926
+ {
927
+ "inline_data": {
928
+ "data": content.source.data,
929
+ "mime_type": content.source.media_type,
930
+ }
931
+ }
932
+ )
933
+ else:
934
+ raise ValueError(f"Unsupported content type: {content.type}")
935
+
891
936
  google_ai_message = {
892
937
  "role": "user",
893
- "parts": [{"text": text_content}],
938
+ "parts": content_parts,
894
939
  }
895
940
 
896
941
  elif self.role == "assistant":
@@ -1006,8 +1051,10 @@ class Message(BaseMessage):
1006
1051
  # embedded function calls in multi-turn conversation become more clear
1007
1052
  if self.content and len(self.content) == 1 and isinstance(self.content[0], TextContent):
1008
1053
  text_content = self.content[0].text
1009
- if self.content and len(self.content) == 1 and isinstance(self.content[0], ToolReturnContent):
1054
+ elif self.content and len(self.content) == 1 and isinstance(self.content[0], ToolReturnContent):
1010
1055
  text_content = self.content[0].content
1056
+ elif self.content and len(self.content) == 1 and isinstance(self.content[0], ImageContent):
1057
+ text_content = "[Image Here]"
1011
1058
  else:
1012
1059
  text_content = None
1013
1060
  if self.role == "system":
@@ -10,7 +10,7 @@ class SystemMessage(BaseModel):
10
10
 
11
11
 
12
12
  class UserMessage(BaseModel):
13
- content: Union[str, List[str]]
13
+ content: Union[str, List[str], List[dict]]
14
14
  role: str = "user"
15
15
  name: Optional[str] = None
16
16
 
@@ -2,6 +2,8 @@ import warnings
2
2
  from datetime import datetime
3
3
  from typing import List, Literal, Optional
4
4
 
5
+ import aiohttp
6
+ import requests
5
7
  from pydantic import BaseModel, Field, model_validator
6
8
 
7
9
  from letta.constants import DEFAULT_EMBEDDING_CHUNK_SIZE, LETTA_MODEL_ENDPOINT, LLM_MAX_TOKENS, MIN_CONTEXT_WINDOW
@@ -872,9 +874,6 @@ class OllamaProvider(OpenAIProvider):
872
874
  async def list_llm_models_async(self) -> List[LLMConfig]:
873
875
  """Async version of list_llm_models below"""
874
876
  endpoint = f"{self.base_url}/api/tags"
875
-
876
- import aiohttp
877
-
878
877
  async with aiohttp.ClientSession() as session:
879
878
  async with session.get(endpoint) as response:
880
879
  if response.status != 200:
@@ -903,8 +902,6 @@ class OllamaProvider(OpenAIProvider):
903
902
 
904
903
  def list_llm_models(self) -> List[LLMConfig]:
905
904
  # https://github.com/ollama/ollama/blob/main/docs/api.md#list-local-models
906
- import requests
907
-
908
905
  response = requests.get(f"{self.base_url}/api/tags")
909
906
  if response.status_code != 200:
910
907
  raise Exception(f"Failed to list Ollama models: {response.text}")
@@ -931,9 +928,6 @@ class OllamaProvider(OpenAIProvider):
931
928
  return configs
932
929
 
933
930
  def get_model_context_window(self, model_name: str) -> Optional[int]:
934
-
935
- import requests
936
-
937
931
  response = requests.post(f"{self.base_url}/api/show", json={"name": model_name, "verbose": True})
938
932
  response_json = response.json()
939
933
 
@@ -965,11 +959,19 @@ class OllamaProvider(OpenAIProvider):
965
959
  return value
966
960
  return None
967
961
 
968
- def get_model_embedding_dim(self, model_name: str):
969
- import requests
970
-
962
+ def _get_model_embedding_dim(self, model_name: str):
971
963
  response = requests.post(f"{self.base_url}/api/show", json={"name": model_name, "verbose": True})
972
964
  response_json = response.json()
965
+ return self._get_model_embedding_dim_impl(response_json, model_name)
966
+
967
+ async def _get_model_embedding_dim_async(self, model_name: str):
968
+ async with aiohttp.ClientSession() as session:
969
+ async with session.post(f"{self.base_url}/api/show", json={"name": model_name, "verbose": True}) as response:
970
+ response_json = await response.json()
971
+ return self._get_model_embedding_dim_impl(response_json, model_name)
972
+
973
+ @staticmethod
974
+ def _get_model_embedding_dim_impl(response_json: dict, model_name: str):
973
975
  if "model_info" not in response_json:
974
976
  if "error" in response_json:
975
977
  print(f"Ollama fetch model info error for {model_name}: {response_json['error']}")
@@ -979,10 +981,35 @@ class OllamaProvider(OpenAIProvider):
979
981
  return value
980
982
  return None
981
983
 
984
+ async def list_embedding_models_async(self) -> List[EmbeddingConfig]:
985
+ """Async version of list_embedding_models below"""
986
+ endpoint = f"{self.base_url}/api/tags"
987
+ async with aiohttp.ClientSession() as session:
988
+ async with session.get(endpoint) as response:
989
+ if response.status != 200:
990
+ raise Exception(f"Failed to list Ollama models: {response.text}")
991
+ response_json = await response.json()
992
+
993
+ configs = []
994
+ for model in response_json["models"]:
995
+ embedding_dim = await self._get_model_embedding_dim_async(model["name"])
996
+ if not embedding_dim:
997
+ print(f"Ollama model {model['name']} has no embedding dimension")
998
+ continue
999
+ configs.append(
1000
+ EmbeddingConfig(
1001
+ embedding_model=model["name"],
1002
+ embedding_endpoint_type="ollama",
1003
+ embedding_endpoint=self.base_url,
1004
+ embedding_dim=embedding_dim,
1005
+ embedding_chunk_size=300,
1006
+ handle=self.get_handle(model["name"], is_embedding=True),
1007
+ )
1008
+ )
1009
+ return configs
1010
+
982
1011
  def list_embedding_models(self) -> List[EmbeddingConfig]:
983
1012
  # https://github.com/ollama/ollama/blob/main/docs/api.md#list-local-models
984
- import requests
985
-
986
1013
  response = requests.get(f"{self.base_url}/api/tags")
987
1014
  if response.status_code != 200:
988
1015
  raise Exception(f"Failed to list Ollama models: {response.text}")
@@ -990,7 +1017,7 @@ class OllamaProvider(OpenAIProvider):
990
1017
 
991
1018
  configs = []
992
1019
  for model in response_json["models"]:
993
- embedding_dim = self.get_model_embedding_dim(model["name"])
1020
+ embedding_dim = self._get_model_embedding_dim(model["name"])
994
1021
  if not embedding_dim:
995
1022
  print(f"Ollama model {model['name']} has no embedding dimension")
996
1023
  continue
@@ -1,20 +1,48 @@
1
1
  import json
2
+ import logging
2
3
  from typing import Annotated, Any, Dict, List, Literal, Optional, Set, Union
3
4
 
5
+ from jinja2 import Template
4
6
  from pydantic import Field
5
7
 
6
8
  from letta.schemas.enums import ToolRuleType
7
9
  from letta.schemas.letta_base import LettaBase
8
10
 
11
+ logger = logging.getLogger(__name__)
12
+
9
13
 
10
14
  class BaseToolRule(LettaBase):
11
15
  __id_prefix__ = "tool_rule"
12
16
  tool_name: str = Field(..., description="The name of the tool. Must exist in the database for the user's organization.")
13
17
  type: ToolRuleType = Field(..., description="The type of the message.")
18
+ prompt_template: Optional[str] = Field(
19
+ None,
20
+ description="Optional Jinja2 template for generating agent prompt about this tool rule. Template can use variables like 'tool_name' and rule-specific attributes.",
21
+ )
14
22
 
15
23
  def get_valid_tools(self, tool_call_history: List[str], available_tools: Set[str], last_function_response: Optional[str]) -> set[str]:
16
24
  raise NotImplementedError
17
25
 
26
+ def render_prompt(self) -> Optional[str]:
27
+ """Render the prompt template with this rule's attributes."""
28
+ template_to_use = self.prompt_template or self._get_default_template()
29
+ if not template_to_use:
30
+ return None
31
+
32
+ try:
33
+ template = Template(template_to_use)
34
+ return template.render(**self.model_dump())
35
+ except Exception as e:
36
+ logger.warning(
37
+ f"Failed to render prompt template for tool rule '{self.tool_name}' (type: {self.type}). "
38
+ f"Template: '{template_to_use}'. Error: {e}"
39
+ )
40
+ return None
41
+
42
+ def _get_default_template(self) -> Optional[str]:
43
+ """Get the default template for this rule type. Override in subclasses."""
44
+ return None
45
+
18
46
 
19
47
  class ChildToolRule(BaseToolRule):
20
48
  """
@@ -23,11 +51,18 @@ class ChildToolRule(BaseToolRule):
23
51
 
24
52
  type: Literal[ToolRuleType.constrain_child_tools] = ToolRuleType.constrain_child_tools
25
53
  children: List[str] = Field(..., description="The children tools that can be invoked.")
54
+ prompt_template: Optional[str] = Field(
55
+ default="<tool_constraint>After using {{ tool_name }}, you can only use these tools: {{ children | join(', ') }}</tool_constraint>",
56
+ description="Optional Jinja2 template for generating agent prompt about this tool rule.",
57
+ )
26
58
 
27
59
  def get_valid_tools(self, tool_call_history: List[str], available_tools: Set[str], last_function_response: Optional[str]) -> Set[str]:
28
60
  last_tool = tool_call_history[-1] if tool_call_history else None
29
61
  return set(self.children) if last_tool == self.tool_name else available_tools
30
62
 
63
+ def _get_default_template(self) -> Optional[str]:
64
+ return "<tool_constraint>After using {{ tool_name }}, you can only use these tools: {{ children | join(', ') }}</tool_constraint>"
65
+
31
66
 
32
67
  class ParentToolRule(BaseToolRule):
33
68
  """
@@ -36,11 +71,18 @@ class ParentToolRule(BaseToolRule):
36
71
 
37
72
  type: Literal[ToolRuleType.parent_last_tool] = ToolRuleType.parent_last_tool
38
73
  children: List[str] = Field(..., description="The children tools that can be invoked.")
74
+ prompt_template: Optional[str] = Field(
75
+ default="<tool_constraint>{{ children | join(', ') }} can only be used after {{ tool_name }}</tool_constraint>",
76
+ description="Optional Jinja2 template for generating agent prompt about this tool rule.",
77
+ )
39
78
 
40
79
  def get_valid_tools(self, tool_call_history: List[str], available_tools: Set[str], last_function_response: Optional[str]) -> Set[str]:
41
80
  last_tool = tool_call_history[-1] if tool_call_history else None
42
81
  return set(self.children) if last_tool == self.tool_name else available_tools - set(self.children)
43
82
 
83
+ def _get_default_template(self) -> Optional[str]:
84
+ return "<tool_constraint>{{ children | join(', ') }} can only be used after {{ tool_name }}</tool_constraint>"
85
+
44
86
 
45
87
  class ConditionalToolRule(BaseToolRule):
46
88
  """
@@ -51,6 +93,10 @@ class ConditionalToolRule(BaseToolRule):
51
93
  default_child: Optional[str] = Field(None, description="The default child tool to be called. If None, any tool can be called.")
52
94
  child_output_mapping: Dict[Any, str] = Field(..., description="The output case to check for mapping")
53
95
  require_output_mapping: bool = Field(default=False, description="Whether to throw an error when output doesn't match any case")
96
+ prompt_template: Optional[str] = Field(
97
+ default="<tool_constraint>{{ tool_name }} will determine which tool to use next based on its output</tool_constraint>",
98
+ description="Optional Jinja2 template for generating agent prompt about this tool rule.",
99
+ )
54
100
 
55
101
  def get_valid_tools(self, tool_call_history: List[str], available_tools: Set[str], last_function_response: Optional[str]) -> Set[str]:
56
102
  """Determine valid tools based on function output mapping."""
@@ -96,6 +142,9 @@ class ConditionalToolRule(BaseToolRule):
96
142
  else: # Assume string
97
143
  return str(function_output) == str(key)
98
144
 
145
+ def _get_default_template(self) -> Optional[str]:
146
+ return "<tool_constraint>{{ tool_name }} will determine which tool to use next based on its output</tool_constraint>"
147
+
99
148
 
100
149
  class InitToolRule(BaseToolRule):
101
150
  """
@@ -111,6 +160,13 @@ class TerminalToolRule(BaseToolRule):
111
160
  """
112
161
 
113
162
  type: Literal[ToolRuleType.exit_loop] = ToolRuleType.exit_loop
163
+ prompt_template: Optional[str] = Field(
164
+ default="<tool_constraint>{{ tool_name }} ends the conversation when called</tool_constraint>",
165
+ description="Optional Jinja2 template for generating agent prompt about this tool rule.",
166
+ )
167
+
168
+ def _get_default_template(self) -> Optional[str]:
169
+ return "<tool_constraint>{{ tool_name }} ends the conversation when called</tool_constraint>"
114
170
 
115
171
 
116
172
  class ContinueToolRule(BaseToolRule):
@@ -119,6 +175,10 @@ class ContinueToolRule(BaseToolRule):
119
175
  """
120
176
 
121
177
  type: Literal[ToolRuleType.continue_loop] = ToolRuleType.continue_loop
178
+ prompt_template: Optional[str] = Field(
179
+ default="<tool_constraint>{{ tool_name }} requires continuing the conversation when called</tool_constraint>",
180
+ description="Optional Jinja2 template for generating agent prompt about this tool rule.",
181
+ )
122
182
 
123
183
 
124
184
  class MaxCountPerStepToolRule(BaseToolRule):
@@ -128,6 +188,10 @@ class MaxCountPerStepToolRule(BaseToolRule):
128
188
 
129
189
  type: Literal[ToolRuleType.max_count_per_step] = ToolRuleType.max_count_per_step
130
190
  max_count_limit: int = Field(..., description="The max limit for the total number of times this tool can be invoked in a single step.")
191
+ prompt_template: Optional[str] = Field(
192
+ default="<tool_constraint>{{ tool_name }}: max {{ max_count_limit }} use(s) per turn</tool_constraint>",
193
+ description="Optional Jinja2 template for generating agent prompt about this tool rule.",
194
+ )
131
195
 
132
196
  def get_valid_tools(self, tool_call_history: List[str], available_tools: Set[str], last_function_response: Optional[str]) -> Set[str]:
133
197
  """Restricts the tool if it has been called max_count_limit times in the current step."""
@@ -139,6 +203,9 @@ class MaxCountPerStepToolRule(BaseToolRule):
139
203
 
140
204
  return available_tools
141
205
 
206
+ def _get_default_template(self) -> Optional[str]:
207
+ return "<tool_constraint>{{ tool_name }}: max {{ max_count_limit }} use(s) per turn</tool_constraint>"
208
+
142
209
 
143
210
  ToolRule = Annotated[
144
211
  Union[ChildToolRule, InitToolRule, TerminalToolRule, ConditionalToolRule, ContinueToolRule, MaxCountPerStepToolRule, ParentToolRule],
letta/schemas/user.py CHANGED
@@ -3,8 +3,8 @@ from typing import Optional
3
3
 
4
4
  from pydantic import Field
5
5
 
6
+ from letta.constants import DEFAULT_ORG_ID
6
7
  from letta.schemas.letta_base import LettaBase
7
- from letta.services.organization_manager import OrganizationManager
8
8
 
9
9
 
10
10
  class UserBase(LettaBase):
@@ -22,7 +22,7 @@ class User(UserBase):
22
22
  """
23
23
 
24
24
  id: str = UserBase.generate_id_field()
25
- organization_id: Optional[str] = Field(OrganizationManager.DEFAULT_ORG_ID, description="The organization id of the user")
25
+ organization_id: Optional[str] = Field(DEFAULT_ORG_ID, description="The organization id of the user")
26
26
  name: str = Field(..., description="The name of the user.")
27
27
  created_at: Optional[datetime] = Field(default_factory=datetime.utcnow, description="The creation date of the user.")
28
28
  updated_at: Optional[datetime] = Field(default_factory=datetime.utcnow, description="The update date of the user.")