letta-nightly 0.8.3.dev20250612104349__py3-none-any.whl → 0.8.4.dev20250613104250__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.
- letta/__init__.py +1 -1
- letta/agent.py +11 -1
- letta/agents/base_agent.py +11 -4
- letta/agents/ephemeral_summary_agent.py +3 -2
- letta/agents/letta_agent.py +109 -78
- letta/agents/letta_agent_batch.py +4 -3
- letta/agents/voice_agent.py +3 -3
- letta/agents/voice_sleeptime_agent.py +3 -2
- letta/client/client.py +6 -3
- letta/constants.py +6 -0
- letta/data_sources/connectors.py +3 -5
- letta/functions/async_composio_toolset.py +4 -1
- letta/functions/function_sets/files.py +4 -3
- letta/functions/schema_generator.py +5 -2
- letta/groups/sleeptime_multi_agent_v2.py +4 -3
- letta/helpers/converters.py +7 -1
- letta/helpers/message_helper.py +31 -11
- letta/helpers/tool_rule_solver.py +69 -4
- letta/interfaces/anthropic_streaming_interface.py +8 -1
- letta/interfaces/openai_streaming_interface.py +4 -1
- letta/llm_api/anthropic_client.py +4 -4
- letta/llm_api/openai_client.py +56 -11
- letta/local_llm/utils.py +3 -20
- letta/orm/sqlalchemy_base.py +7 -1
- letta/otel/metric_registry.py +26 -0
- letta/otel/metrics.py +78 -14
- letta/schemas/letta_message_content.py +64 -3
- letta/schemas/letta_request.py +5 -1
- letta/schemas/message.py +61 -14
- letta/schemas/openai/chat_completion_request.py +1 -1
- letta/schemas/providers.py +41 -14
- letta/schemas/tool_rule.py +67 -0
- letta/schemas/user.py +2 -2
- letta/server/rest_api/routers/v1/agents.py +22 -12
- letta/server/rest_api/routers/v1/sources.py +13 -25
- letta/server/server.py +10 -5
- letta/services/agent_manager.py +5 -1
- letta/services/file_manager.py +219 -0
- letta/services/file_processor/chunker/line_chunker.py +119 -14
- letta/services/file_processor/file_processor.py +8 -8
- letta/services/file_processor/file_types.py +303 -0
- letta/services/file_processor/parser/mistral_parser.py +2 -11
- letta/services/helpers/agent_manager_helper.py +6 -0
- letta/services/message_manager.py +32 -0
- letta/services/organization_manager.py +4 -6
- letta/services/passage_manager.py +1 -0
- letta/services/source_manager.py +0 -208
- letta/services/tool_executor/composio_tool_executor.py +5 -1
- letta/services/tool_executor/files_tool_executor.py +291 -15
- letta/services/user_manager.py +8 -8
- letta/system.py +3 -1
- letta/utils.py +7 -13
- {letta_nightly-0.8.3.dev20250612104349.dist-info → letta_nightly-0.8.4.dev20250613104250.dist-info}/METADATA +2 -2
- {letta_nightly-0.8.3.dev20250612104349.dist-info → letta_nightly-0.8.4.dev20250613104250.dist-info}/RECORD +57 -55
- {letta_nightly-0.8.3.dev20250612104349.dist-info → letta_nightly-0.8.4.dev20250613104250.dist-info}/LICENSE +0 -0
- {letta_nightly-0.8.3.dev20250612104349.dist-info → letta_nightly-0.8.4.dev20250613104250.dist-info}/WHEEL +0 -0
- {letta_nightly-0.8.3.dev20250612104349.dist-info → letta_nightly-0.8.4.dev20250613104250.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
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
}
|
29
|
-
|
30
|
-
|
31
|
-
|
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
|
-
|
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
|
-
#
|
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[
|
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",
|
letta/schemas/letta_request.py
CHANGED
@@ -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
|
-
|
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=
|
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
|
-
|
665
|
-
|
666
|
-
|
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
|
-
|
782
|
-
|
783
|
-
|
784
|
-
|
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
|
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":
|
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
|
-
|
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":
|
letta/schemas/providers.py
CHANGED
@@ -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
|
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.
|
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
|
letta/schemas/tool_rule.py
CHANGED
@@ -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(
|
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.")
|