letta-nightly 0.6.38.dev20250312104155__py3-none-any.whl → 0.6.39.dev20250313162623__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of letta-nightly might be problematic. Click here for more details.
- letta/__init__.py +1 -1
- letta/agent.py +49 -11
- letta/agents/low_latency_agent.py +3 -2
- letta/constants.py +3 -0
- letta/functions/function_sets/base.py +1 -1
- letta/functions/helpers.py +14 -0
- letta/functions/schema_generator.py +47 -0
- letta/helpers/mcp_helpers.py +108 -0
- letta/llm_api/cohere.py +1 -1
- letta/llm_api/helpers.py +1 -2
- letta/llm_api/llm_api_tools.py +0 -1
- letta/local_llm/utils.py +30 -20
- letta/log.py +1 -1
- letta/memory.py +1 -1
- letta/orm/__init__.py +1 -0
- letta/orm/block.py +8 -0
- letta/orm/enums.py +2 -0
- letta/orm/identities_blocks.py +13 -0
- letta/orm/identity.py +9 -0
- letta/orm/sqlalchemy_base.py +4 -4
- letta/schemas/identity.py +3 -0
- letta/schemas/message.py +68 -62
- letta/schemas/tool.py +39 -2
- letta/server/rest_api/app.py +15 -0
- letta/server/rest_api/chat_completions_interface.py +2 -0
- letta/server/rest_api/interface.py +46 -13
- letta/server/rest_api/routers/v1/agents.py +2 -2
- letta/server/rest_api/routers/v1/blocks.py +5 -1
- letta/server/rest_api/routers/v1/tools.py +71 -1
- letta/server/server.py +102 -5
- letta/services/agent_manager.py +2 -0
- letta/services/block_manager.py +10 -1
- letta/services/identity_manager.py +54 -14
- letta/services/summarizer/summarizer.py +1 -1
- letta/services/tool_manager.py +6 -0
- letta/settings.py +11 -12
- {letta_nightly-0.6.38.dev20250312104155.dist-info → letta_nightly-0.6.39.dev20250313162623.dist-info}/METADATA +4 -3
- {letta_nightly-0.6.38.dev20250312104155.dist-info → letta_nightly-0.6.39.dev20250313162623.dist-info}/RECORD +41 -39
- {letta_nightly-0.6.38.dev20250312104155.dist-info → letta_nightly-0.6.39.dev20250313162623.dist-info}/LICENSE +0 -0
- {letta_nightly-0.6.38.dev20250312104155.dist-info → letta_nightly-0.6.39.dev20250313162623.dist-info}/WHEEL +0 -0
- {letta_nightly-0.6.38.dev20250312104155.dist-info → letta_nightly-0.6.39.dev20250313162623.dist-info}/entry_points.txt +0 -0
letta/schemas/message.py
CHANGED
|
@@ -158,19 +158,6 @@ class Message(BaseMessage):
|
|
|
158
158
|
del data["content"]
|
|
159
159
|
return data
|
|
160
160
|
|
|
161
|
-
@property
|
|
162
|
-
def text(self) -> Optional[str]:
|
|
163
|
-
"""
|
|
164
|
-
Retrieve the first text content's text.
|
|
165
|
-
|
|
166
|
-
Returns:
|
|
167
|
-
str: The text content, or None if no text content exists
|
|
168
|
-
"""
|
|
169
|
-
if not self.content:
|
|
170
|
-
return None
|
|
171
|
-
text_content = [content.text for content in self.content if content.type == MessageContentType.text]
|
|
172
|
-
return text_content[0] if text_content else None
|
|
173
|
-
|
|
174
161
|
def to_json(self):
|
|
175
162
|
json_message = vars(self)
|
|
176
163
|
if json_message["tool_calls"] is not None:
|
|
@@ -227,17 +214,21 @@ class Message(BaseMessage):
|
|
|
227
214
|
assistant_message_tool_kwarg: str = DEFAULT_MESSAGE_TOOL_KWARG,
|
|
228
215
|
) -> List[LettaMessage]:
|
|
229
216
|
"""Convert message object (in DB format) to the style used by the original Letta API"""
|
|
217
|
+
if self.content and len(self.content) == 1 and self.content[0].type == MessageContentType.text:
|
|
218
|
+
text_content = self.content[0].text
|
|
219
|
+
else:
|
|
220
|
+
text_content = None
|
|
230
221
|
|
|
231
222
|
messages = []
|
|
232
223
|
|
|
233
224
|
if self.role == MessageRole.assistant:
|
|
234
|
-
if
|
|
225
|
+
if text_content is not None:
|
|
235
226
|
# This is type InnerThoughts
|
|
236
227
|
messages.append(
|
|
237
228
|
ReasoningMessage(
|
|
238
229
|
id=self.id,
|
|
239
230
|
date=self.created_at,
|
|
240
|
-
reasoning=
|
|
231
|
+
reasoning=text_content,
|
|
241
232
|
)
|
|
242
233
|
)
|
|
243
234
|
if self.tool_calls is not None:
|
|
@@ -281,9 +272,9 @@ class Message(BaseMessage):
|
|
|
281
272
|
# "message": response_string,
|
|
282
273
|
# "time": formatted_time,
|
|
283
274
|
# }
|
|
284
|
-
assert
|
|
275
|
+
assert text_content is not None, self
|
|
285
276
|
try:
|
|
286
|
-
function_return = json.loads(
|
|
277
|
+
function_return = json.loads(text_content)
|
|
287
278
|
status = function_return["status"]
|
|
288
279
|
if status == "OK":
|
|
289
280
|
status_enum = "success"
|
|
@@ -292,7 +283,7 @@ class Message(BaseMessage):
|
|
|
292
283
|
else:
|
|
293
284
|
raise ValueError(f"Invalid status: {status}")
|
|
294
285
|
except json.JSONDecodeError:
|
|
295
|
-
raise ValueError(f"Failed to decode function return: {
|
|
286
|
+
raise ValueError(f"Failed to decode function return: {text_content}")
|
|
296
287
|
assert self.tool_call_id is not None
|
|
297
288
|
messages.append(
|
|
298
289
|
# TODO make sure this is what the API returns
|
|
@@ -300,7 +291,7 @@ class Message(BaseMessage):
|
|
|
300
291
|
ToolReturnMessage(
|
|
301
292
|
id=self.id,
|
|
302
293
|
date=self.created_at,
|
|
303
|
-
tool_return=
|
|
294
|
+
tool_return=text_content,
|
|
304
295
|
status=self.tool_returns[0].status if self.tool_returns else status_enum,
|
|
305
296
|
tool_call_id=self.tool_call_id,
|
|
306
297
|
stdout=self.tool_returns[0].stdout if self.tool_returns else None,
|
|
@@ -309,23 +300,23 @@ class Message(BaseMessage):
|
|
|
309
300
|
)
|
|
310
301
|
elif self.role == MessageRole.user:
|
|
311
302
|
# This is type UserMessage
|
|
312
|
-
assert
|
|
313
|
-
message_str = unpack_message(
|
|
303
|
+
assert text_content is not None, self
|
|
304
|
+
message_str = unpack_message(text_content)
|
|
314
305
|
messages.append(
|
|
315
306
|
UserMessage(
|
|
316
307
|
id=self.id,
|
|
317
308
|
date=self.created_at,
|
|
318
|
-
content=message_str or
|
|
309
|
+
content=message_str or text_content,
|
|
319
310
|
)
|
|
320
311
|
)
|
|
321
312
|
elif self.role == MessageRole.system:
|
|
322
313
|
# This is type SystemMessage
|
|
323
|
-
assert
|
|
314
|
+
assert text_content is not None, self
|
|
324
315
|
messages.append(
|
|
325
316
|
SystemMessage(
|
|
326
317
|
id=self.id,
|
|
327
318
|
date=self.created_at,
|
|
328
|
-
content=
|
|
319
|
+
content=text_content,
|
|
329
320
|
)
|
|
330
321
|
)
|
|
331
322
|
else:
|
|
@@ -494,11 +485,15 @@ class Message(BaseMessage):
|
|
|
494
485
|
"""Go from Message class to ChatCompletion message object"""
|
|
495
486
|
|
|
496
487
|
# TODO change to pydantic casting, eg `return SystemMessageModel(self)`
|
|
488
|
+
if self.content and len(self.content) == 1 and self.content[0].type == MessageContentType.text:
|
|
489
|
+
text_content = self.content[0].text
|
|
490
|
+
else:
|
|
491
|
+
text_content = None
|
|
497
492
|
|
|
498
493
|
if self.role == "system":
|
|
499
494
|
assert all([v is not None for v in [self.role]]), vars(self)
|
|
500
495
|
openai_message = {
|
|
501
|
-
"content":
|
|
496
|
+
"content": text_content,
|
|
502
497
|
"role": self.role,
|
|
503
498
|
}
|
|
504
499
|
# Optional field, do not include if null
|
|
@@ -506,9 +501,9 @@ class Message(BaseMessage):
|
|
|
506
501
|
openai_message["name"] = self.name
|
|
507
502
|
|
|
508
503
|
elif self.role == "user":
|
|
509
|
-
assert all([v is not None for v in [
|
|
504
|
+
assert all([v is not None for v in [text_content, self.role]]), vars(self)
|
|
510
505
|
openai_message = {
|
|
511
|
-
"content":
|
|
506
|
+
"content": text_content,
|
|
512
507
|
"role": self.role,
|
|
513
508
|
}
|
|
514
509
|
# Optional field, do not include if null
|
|
@@ -516,9 +511,9 @@ class Message(BaseMessage):
|
|
|
516
511
|
openai_message["name"] = self.name
|
|
517
512
|
|
|
518
513
|
elif self.role == "assistant":
|
|
519
|
-
assert self.tool_calls is not None or
|
|
514
|
+
assert self.tool_calls is not None or text_content is not None
|
|
520
515
|
openai_message = {
|
|
521
|
-
"content": None if put_inner_thoughts_in_kwargs else
|
|
516
|
+
"content": None if put_inner_thoughts_in_kwargs else text_content,
|
|
522
517
|
"role": self.role,
|
|
523
518
|
}
|
|
524
519
|
# Optional fields, do not include if null
|
|
@@ -530,7 +525,7 @@ class Message(BaseMessage):
|
|
|
530
525
|
openai_message["tool_calls"] = [
|
|
531
526
|
add_inner_thoughts_to_tool_call(
|
|
532
527
|
tool_call,
|
|
533
|
-
inner_thoughts=
|
|
528
|
+
inner_thoughts=text_content,
|
|
534
529
|
inner_thoughts_key=INNER_THOUGHTS_KWARG,
|
|
535
530
|
).model_dump()
|
|
536
531
|
for tool_call in self.tool_calls
|
|
@@ -544,7 +539,7 @@ class Message(BaseMessage):
|
|
|
544
539
|
elif self.role == "tool":
|
|
545
540
|
assert all([v is not None for v in [self.role, self.tool_call_id]]), vars(self)
|
|
546
541
|
openai_message = {
|
|
547
|
-
"content":
|
|
542
|
+
"content": text_content,
|
|
548
543
|
"role": self.role,
|
|
549
544
|
"tool_call_id": self.tool_call_id[:max_tool_id_length] if max_tool_id_length else self.tool_call_id,
|
|
550
545
|
}
|
|
@@ -565,6 +560,10 @@ class Message(BaseMessage):
|
|
|
565
560
|
Args:
|
|
566
561
|
inner_thoughts_xml_tag (str): The XML tag to wrap around inner thoughts
|
|
567
562
|
"""
|
|
563
|
+
if self.content and len(self.content) == 1 and self.content[0].type == MessageContentType.text:
|
|
564
|
+
text_content = self.content[0].text
|
|
565
|
+
else:
|
|
566
|
+
text_content = None
|
|
568
567
|
|
|
569
568
|
def add_xml_tag(string: str, xml_tag: Optional[str]):
|
|
570
569
|
# NOTE: Anthropic docs recommends using <thinking> tag when using CoT + tool use
|
|
@@ -573,34 +572,34 @@ class Message(BaseMessage):
|
|
|
573
572
|
if self.role == "system":
|
|
574
573
|
# NOTE: this is not for system instructions, but instead system "events"
|
|
575
574
|
|
|
576
|
-
assert all([v is not None for v in [
|
|
575
|
+
assert all([v is not None for v in [text_content, self.role]]), vars(self)
|
|
577
576
|
# Two options here, we would use system.package_system_message,
|
|
578
577
|
# or use a more Anthropic-specific packaging ie xml tags
|
|
579
|
-
user_system_event = add_xml_tag(string=f"SYSTEM ALERT: {
|
|
578
|
+
user_system_event = add_xml_tag(string=f"SYSTEM ALERT: {text_content}", xml_tag="event")
|
|
580
579
|
anthropic_message = {
|
|
581
580
|
"content": user_system_event,
|
|
582
581
|
"role": "user",
|
|
583
582
|
}
|
|
584
583
|
|
|
585
584
|
elif self.role == "user":
|
|
586
|
-
assert all([v is not None for v in [
|
|
585
|
+
assert all([v is not None for v in [text_content, self.role]]), vars(self)
|
|
587
586
|
anthropic_message = {
|
|
588
|
-
"content":
|
|
587
|
+
"content": text_content,
|
|
589
588
|
"role": self.role,
|
|
590
589
|
}
|
|
591
590
|
|
|
592
591
|
elif self.role == "assistant":
|
|
593
|
-
assert self.tool_calls is not None or
|
|
592
|
+
assert self.tool_calls is not None or text_content is not None
|
|
594
593
|
anthropic_message = {
|
|
595
594
|
"role": self.role,
|
|
596
595
|
}
|
|
597
596
|
content = []
|
|
598
597
|
# COT / reasoning / thinking
|
|
599
|
-
if
|
|
598
|
+
if text_content is not None and not put_inner_thoughts_in_kwargs:
|
|
600
599
|
content.append(
|
|
601
600
|
{
|
|
602
601
|
"type": "text",
|
|
603
|
-
"text": add_xml_tag(string=
|
|
602
|
+
"text": add_xml_tag(string=text_content, xml_tag=inner_thoughts_xml_tag),
|
|
604
603
|
}
|
|
605
604
|
)
|
|
606
605
|
# Tool calling
|
|
@@ -610,7 +609,7 @@ class Message(BaseMessage):
|
|
|
610
609
|
if put_inner_thoughts_in_kwargs:
|
|
611
610
|
tool_call_input = add_inner_thoughts_to_tool_call(
|
|
612
611
|
tool_call,
|
|
613
|
-
inner_thoughts=
|
|
612
|
+
inner_thoughts=text_content,
|
|
614
613
|
inner_thoughts_key=INNER_THOUGHTS_KWARG,
|
|
615
614
|
).model_dump()
|
|
616
615
|
else:
|
|
@@ -639,7 +638,7 @@ class Message(BaseMessage):
|
|
|
639
638
|
{
|
|
640
639
|
"type": "tool_result",
|
|
641
640
|
"tool_use_id": self.tool_call_id,
|
|
642
|
-
"content":
|
|
641
|
+
"content": text_content,
|
|
643
642
|
}
|
|
644
643
|
],
|
|
645
644
|
}
|
|
@@ -656,6 +655,10 @@ class Message(BaseMessage):
|
|
|
656
655
|
# type Content: https://ai.google.dev/api/rest/v1/Content / https://ai.google.dev/api/rest/v1beta/Content
|
|
657
656
|
# parts[]: Part
|
|
658
657
|
# role: str ('user' or 'model')
|
|
658
|
+
if self.content and len(self.content) == 1 and self.content[0].type == MessageContentType.text:
|
|
659
|
+
text_content = self.content[0].text
|
|
660
|
+
else:
|
|
661
|
+
text_content = None
|
|
659
662
|
|
|
660
663
|
if self.role != "tool" and self.name is not None:
|
|
661
664
|
warnings.warn(f"Using Google AI with non-null 'name' field ({self.name}) not yet supported.")
|
|
@@ -665,18 +668,18 @@ class Message(BaseMessage):
|
|
|
665
668
|
# https://www.reddit.com/r/Bard/comments/1b90i8o/does_gemini_have_a_system_prompt_option_while/
|
|
666
669
|
google_ai_message = {
|
|
667
670
|
"role": "user", # NOTE: no 'system'
|
|
668
|
-
"parts": [{"text":
|
|
671
|
+
"parts": [{"text": text_content}],
|
|
669
672
|
}
|
|
670
673
|
|
|
671
674
|
elif self.role == "user":
|
|
672
|
-
assert all([v is not None for v in [
|
|
675
|
+
assert all([v is not None for v in [text_content, self.role]]), vars(self)
|
|
673
676
|
google_ai_message = {
|
|
674
677
|
"role": "user",
|
|
675
|
-
"parts": [{"text":
|
|
678
|
+
"parts": [{"text": text_content}],
|
|
676
679
|
}
|
|
677
680
|
|
|
678
681
|
elif self.role == "assistant":
|
|
679
|
-
assert self.tool_calls is not None or
|
|
682
|
+
assert self.tool_calls is not None or text_content is not None
|
|
680
683
|
google_ai_message = {
|
|
681
684
|
"role": "model", # NOTE: different
|
|
682
685
|
}
|
|
@@ -684,10 +687,10 @@ class Message(BaseMessage):
|
|
|
684
687
|
# NOTE: Google AI API doesn't allow non-null content + function call
|
|
685
688
|
# To get around this, just two a two part message, inner thoughts first then
|
|
686
689
|
parts = []
|
|
687
|
-
if not put_inner_thoughts_in_kwargs and
|
|
690
|
+
if not put_inner_thoughts_in_kwargs and text_content is not None:
|
|
688
691
|
# NOTE: ideally we do multi-part for CoT / inner thoughts + function call, but Google AI API doesn't allow it
|
|
689
692
|
raise NotImplementedError
|
|
690
|
-
parts.append({"text":
|
|
693
|
+
parts.append({"text": text_content})
|
|
691
694
|
|
|
692
695
|
if self.tool_calls is not None:
|
|
693
696
|
# NOTE: implied support for multiple calls
|
|
@@ -701,10 +704,10 @@ class Message(BaseMessage):
|
|
|
701
704
|
raise UserWarning(f"Failed to parse JSON function args: {function_args}")
|
|
702
705
|
function_args = {"args": function_args}
|
|
703
706
|
|
|
704
|
-
if put_inner_thoughts_in_kwargs and
|
|
707
|
+
if put_inner_thoughts_in_kwargs and text_content is not None:
|
|
705
708
|
assert "inner_thoughts" not in function_args, function_args
|
|
706
709
|
assert len(self.tool_calls) == 1
|
|
707
|
-
function_args[INNER_THOUGHTS_KWARG] =
|
|
710
|
+
function_args[INNER_THOUGHTS_KWARG] = text_content
|
|
708
711
|
|
|
709
712
|
parts.append(
|
|
710
713
|
{
|
|
@@ -715,8 +718,8 @@ class Message(BaseMessage):
|
|
|
715
718
|
}
|
|
716
719
|
)
|
|
717
720
|
else:
|
|
718
|
-
assert
|
|
719
|
-
parts.append({"text":
|
|
721
|
+
assert text_content is not None
|
|
722
|
+
parts.append({"text": text_content})
|
|
720
723
|
google_ai_message["parts"] = parts
|
|
721
724
|
|
|
722
725
|
elif self.role == "tool":
|
|
@@ -731,9 +734,9 @@ class Message(BaseMessage):
|
|
|
731
734
|
|
|
732
735
|
# NOTE: Google AI API wants the function response as JSON only, no string
|
|
733
736
|
try:
|
|
734
|
-
function_response = json.loads(
|
|
737
|
+
function_response = json.loads(text_content)
|
|
735
738
|
except:
|
|
736
|
-
function_response = {"function_response":
|
|
739
|
+
function_response = {"function_response": text_content}
|
|
737
740
|
|
|
738
741
|
google_ai_message = {
|
|
739
742
|
"role": "function",
|
|
@@ -778,7 +781,10 @@ class Message(BaseMessage):
|
|
|
778
781
|
|
|
779
782
|
# TODO: update this prompt style once guidance from Cohere on
|
|
780
783
|
# embedded function calls in multi-turn conversation become more clear
|
|
781
|
-
|
|
784
|
+
if self.content and len(self.content) == 1 and self.content[0].type == MessageContentType.text:
|
|
785
|
+
text_content = self.content[0].text
|
|
786
|
+
else:
|
|
787
|
+
text_content = None
|
|
782
788
|
if self.role == "system":
|
|
783
789
|
"""
|
|
784
790
|
The chat_history parameter should not be used for SYSTEM messages in most cases.
|
|
@@ -787,26 +793,26 @@ class Message(BaseMessage):
|
|
|
787
793
|
raise UserWarning(f"role 'system' messages should go in 'preamble' field for Cohere API")
|
|
788
794
|
|
|
789
795
|
elif self.role == "user":
|
|
790
|
-
assert all([v is not None for v in [
|
|
796
|
+
assert all([v is not None for v in [text_content, self.role]]), vars(self)
|
|
791
797
|
cohere_message = [
|
|
792
798
|
{
|
|
793
799
|
"role": "USER",
|
|
794
|
-
"message":
|
|
800
|
+
"message": text_content,
|
|
795
801
|
}
|
|
796
802
|
]
|
|
797
803
|
|
|
798
804
|
elif self.role == "assistant":
|
|
799
805
|
# NOTE: we may break this into two message - an inner thought and a function call
|
|
800
806
|
# Optionally, we could just make this a function call with the inner thought inside
|
|
801
|
-
assert self.tool_calls is not None or
|
|
807
|
+
assert self.tool_calls is not None or text_content is not None
|
|
802
808
|
|
|
803
|
-
if
|
|
809
|
+
if text_content and self.tool_calls:
|
|
804
810
|
if inner_thoughts_as_kwarg:
|
|
805
811
|
raise NotImplementedError
|
|
806
812
|
cohere_message = [
|
|
807
813
|
{
|
|
808
814
|
"role": "CHATBOT",
|
|
809
|
-
"message":
|
|
815
|
+
"message": text_content,
|
|
810
816
|
},
|
|
811
817
|
]
|
|
812
818
|
for tc in self.tool_calls:
|
|
@@ -820,7 +826,7 @@ class Message(BaseMessage):
|
|
|
820
826
|
"message": f"{function_call_prefix} {function_call_text}",
|
|
821
827
|
}
|
|
822
828
|
)
|
|
823
|
-
elif not
|
|
829
|
+
elif not text_content and self.tool_calls:
|
|
824
830
|
cohere_message = []
|
|
825
831
|
for tc in self.tool_calls:
|
|
826
832
|
# TODO better way to pack?
|
|
@@ -831,11 +837,11 @@ class Message(BaseMessage):
|
|
|
831
837
|
"message": f"{function_call_prefix} {function_call_text}",
|
|
832
838
|
}
|
|
833
839
|
)
|
|
834
|
-
elif
|
|
840
|
+
elif text_content and not self.tool_calls:
|
|
835
841
|
cohere_message = [
|
|
836
842
|
{
|
|
837
843
|
"role": "CHATBOT",
|
|
838
|
-
"message":
|
|
844
|
+
"message": text_content,
|
|
839
845
|
}
|
|
840
846
|
]
|
|
841
847
|
else:
|
|
@@ -843,7 +849,7 @@ class Message(BaseMessage):
|
|
|
843
849
|
|
|
844
850
|
elif self.role == "tool":
|
|
845
851
|
assert all([v is not None for v in [self.role, self.tool_call_id]]), vars(self)
|
|
846
|
-
function_response_text =
|
|
852
|
+
function_response_text = text_content
|
|
847
853
|
cohere_message = [
|
|
848
854
|
{
|
|
849
855
|
"role": function_response_role,
|
letta/schemas/tool.py
CHANGED
|
@@ -7,11 +7,22 @@ from letta.constants import (
|
|
|
7
7
|
FUNCTION_RETURN_CHAR_LIMIT,
|
|
8
8
|
LETTA_CORE_TOOL_MODULE_NAME,
|
|
9
9
|
LETTA_MULTI_AGENT_TOOL_MODULE_NAME,
|
|
10
|
+
MCP_TOOL_TAG_NAME_PREFIX,
|
|
10
11
|
)
|
|
11
12
|
from letta.functions.ast_parsers import get_function_name_and_description
|
|
12
13
|
from letta.functions.functions import derive_openai_json_schema, get_json_schema_from_module
|
|
13
|
-
from letta.functions.helpers import
|
|
14
|
-
|
|
14
|
+
from letta.functions.helpers import (
|
|
15
|
+
generate_composio_tool_wrapper,
|
|
16
|
+
generate_langchain_tool_wrapper,
|
|
17
|
+
generate_mcp_tool_wrapper,
|
|
18
|
+
generate_model_from_args_json_schema,
|
|
19
|
+
)
|
|
20
|
+
from letta.functions.schema_generator import (
|
|
21
|
+
generate_schema_from_args_schema_v2,
|
|
22
|
+
generate_tool_schema_for_composio,
|
|
23
|
+
generate_tool_schema_for_mcp,
|
|
24
|
+
)
|
|
25
|
+
from letta.helpers.mcp_helpers import MCPTool
|
|
15
26
|
from letta.log import get_logger
|
|
16
27
|
from letta.orm.enums import ToolType
|
|
17
28
|
from letta.schemas.letta_base import LettaBase
|
|
@@ -121,6 +132,32 @@ class ToolCreate(LettaBase):
|
|
|
121
132
|
args_json_schema: Optional[Dict] = Field(None, description="The args JSON schema of the function.")
|
|
122
133
|
return_char_limit: int = Field(FUNCTION_RETURN_CHAR_LIMIT, description="The maximum number of characters in the response.")
|
|
123
134
|
|
|
135
|
+
# TODO should we put the HTTP / API fetch inside from_mcp?
|
|
136
|
+
# async def from_mcp(cls, mcp_server: str, mcp_tool_name: str) -> "ToolCreate":
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_mcp(cls, mcp_server_name: str, mcp_tool: MCPTool) -> "ToolCreate":
|
|
140
|
+
|
|
141
|
+
# Get the MCP tool from the MCP server
|
|
142
|
+
# NVM
|
|
143
|
+
|
|
144
|
+
# Pass the MCP tool to the schema generator
|
|
145
|
+
json_schema = generate_tool_schema_for_mcp(mcp_tool=mcp_tool)
|
|
146
|
+
|
|
147
|
+
# Return a ToolCreate instance
|
|
148
|
+
description = mcp_tool.description
|
|
149
|
+
source_type = "python"
|
|
150
|
+
tags = [f"{MCP_TOOL_TAG_NAME_PREFIX}:{mcp_server_name}"]
|
|
151
|
+
wrapper_func_name, wrapper_function_str = generate_mcp_tool_wrapper(mcp_tool.name)
|
|
152
|
+
|
|
153
|
+
return cls(
|
|
154
|
+
description=description,
|
|
155
|
+
source_type=source_type,
|
|
156
|
+
tags=tags,
|
|
157
|
+
source_code=wrapper_function_str,
|
|
158
|
+
json_schema=json_schema,
|
|
159
|
+
)
|
|
160
|
+
|
|
124
161
|
@classmethod
|
|
125
162
|
def from_composio(cls, action_name: str) -> "ToolCreate":
|
|
126
163
|
"""
|
letta/server/rest_api/app.py
CHANGED
|
@@ -136,6 +136,21 @@ def create_application() -> "FastAPI":
|
|
|
136
136
|
debug=debug_mode, # if True, the stack trace will be printed in the response
|
|
137
137
|
)
|
|
138
138
|
|
|
139
|
+
@app.on_event("shutdown")
|
|
140
|
+
def shutdown_mcp_clients():
|
|
141
|
+
global server
|
|
142
|
+
import threading
|
|
143
|
+
|
|
144
|
+
def cleanup_clients():
|
|
145
|
+
if hasattr(server, "mcp_clients"):
|
|
146
|
+
for client in server.mcp_clients.values():
|
|
147
|
+
client.cleanup()
|
|
148
|
+
server.mcp_clients.clear()
|
|
149
|
+
|
|
150
|
+
t = threading.Thread(target=cleanup_clients)
|
|
151
|
+
t.start()
|
|
152
|
+
t.join()
|
|
153
|
+
|
|
139
154
|
@app.exception_handler(Exception)
|
|
140
155
|
async def generic_error_handler(request: Request, exc: Exception):
|
|
141
156
|
# Log the actual error for debugging
|
|
@@ -267,3 +267,5 @@ class ChatCompletionsStreamingInterface(AgentChunkStreamingInterface):
|
|
|
267
267
|
"""Clears internal buffers for function call name/args."""
|
|
268
268
|
self.current_function_name = ""
|
|
269
269
|
self.current_function_arguments = []
|
|
270
|
+
self.current_json_parse_result = {}
|
|
271
|
+
self._found_message_tool_kwarg = False
|
|
@@ -24,6 +24,7 @@ from letta.schemas.letta_message import (
|
|
|
24
24
|
)
|
|
25
25
|
from letta.schemas.message import Message
|
|
26
26
|
from letta.schemas.openai.chat_completion_response import ChatCompletionChunkResponse
|
|
27
|
+
from letta.server.rest_api.optimistic_json_parser import OptimisticJSONParser
|
|
27
28
|
from letta.streaming_interface import AgentChunkStreamingInterface
|
|
28
29
|
from letta.streaming_utils import FunctionArgumentsStreamHandler, JSONInnerThoughtsExtractor
|
|
29
30
|
|
|
@@ -282,6 +283,11 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
|
282
283
|
# turn function argument to send_message into a normal text stream
|
|
283
284
|
self.streaming_chat_completion_json_reader = FunctionArgumentsStreamHandler(json_key=assistant_message_tool_kwarg)
|
|
284
285
|
|
|
286
|
+
# @matt's changes here, adopting new optimistic json parser
|
|
287
|
+
self.current_function_arguments = []
|
|
288
|
+
self.optimistic_json_parser = OptimisticJSONParser()
|
|
289
|
+
self.current_json_parse_result = {}
|
|
290
|
+
|
|
285
291
|
# Store metadata passed from server
|
|
286
292
|
self.metadata = {}
|
|
287
293
|
|
|
@@ -374,6 +380,8 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
|
374
380
|
def stream_start(self):
|
|
375
381
|
"""Initialize streaming by activating the generator and clearing any old chunks."""
|
|
376
382
|
self.streaming_chat_completion_mode_function_name = None
|
|
383
|
+
self.current_function_arguments = []
|
|
384
|
+
self.current_json_parse_result = {}
|
|
377
385
|
|
|
378
386
|
if not self._active:
|
|
379
387
|
self._active = True
|
|
@@ -383,6 +391,8 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
|
383
391
|
def stream_end(self):
|
|
384
392
|
"""Clean up the stream by deactivating and clearing chunks."""
|
|
385
393
|
self.streaming_chat_completion_mode_function_name = None
|
|
394
|
+
self.current_function_arguments = []
|
|
395
|
+
self.current_json_parse_result = {}
|
|
386
396
|
|
|
387
397
|
# if not self.streaming_chat_completion_mode and not self.nonstreaming_legacy_mode:
|
|
388
398
|
# self._push_to_buffer(self.multi_step_gen_indicator)
|
|
@@ -568,20 +578,27 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
|
568
578
|
self.streaming_chat_completion_json_reader.reset()
|
|
569
579
|
# early exit to turn into content mode
|
|
570
580
|
return None
|
|
581
|
+
if tool_call.function.arguments:
|
|
582
|
+
self.current_function_arguments.append(tool_call.function.arguments)
|
|
571
583
|
|
|
572
584
|
# if we're in the middle of parsing a send_message, we'll keep processing the JSON chunks
|
|
573
585
|
if tool_call.function.arguments and self.streaming_chat_completion_mode_function_name == self.assistant_message_tool_name:
|
|
574
586
|
# Strip out any extras tokens
|
|
575
|
-
cleaned_func_args = self.streaming_chat_completion_json_reader.process_json_chunk(tool_call.function.arguments)
|
|
576
587
|
# In the case that we just have the prefix of something, no message yet, then we should early exit to move to the next chunk
|
|
577
|
-
|
|
578
|
-
|
|
588
|
+
combined_args = "".join(self.current_function_arguments)
|
|
589
|
+
parsed_args = self.optimistic_json_parser.parse(combined_args)
|
|
590
|
+
|
|
591
|
+
if parsed_args.get(self.assistant_message_tool_kwarg) and parsed_args.get(
|
|
592
|
+
self.assistant_message_tool_kwarg
|
|
593
|
+
) != self.current_json_parse_result.get(self.assistant_message_tool_kwarg):
|
|
594
|
+
new_content = parsed_args.get(self.assistant_message_tool_kwarg)
|
|
595
|
+
prev_content = self.current_json_parse_result.get(self.assistant_message_tool_kwarg, "")
|
|
596
|
+
# TODO: Assumes consistent state and that prev_content is subset of new_content
|
|
597
|
+
diff = new_content.replace(prev_content, "", 1)
|
|
598
|
+
self.current_json_parse_result = parsed_args
|
|
599
|
+
processed_chunk = AssistantMessage(id=message_id, date=message_date, content=diff)
|
|
579
600
|
else:
|
|
580
|
-
|
|
581
|
-
id=message_id,
|
|
582
|
-
date=message_date,
|
|
583
|
-
content=cleaned_func_args,
|
|
584
|
-
)
|
|
601
|
+
return None
|
|
585
602
|
|
|
586
603
|
# otherwise we just do a regular passthrough of a ToolCallDelta via a ToolCallMessage
|
|
587
604
|
else:
|
|
@@ -637,6 +654,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
|
637
654
|
# updates_inner_thoughts = ""
|
|
638
655
|
# else: # OpenAI
|
|
639
656
|
# updates_main_json, updates_inner_thoughts = self.function_args_reader.process_fragment(tool_call.function.arguments)
|
|
657
|
+
self.current_function_arguments.append(tool_call.function.arguments)
|
|
640
658
|
updates_main_json, updates_inner_thoughts = self.function_args_reader.process_fragment(tool_call.function.arguments)
|
|
641
659
|
|
|
642
660
|
# If we have inner thoughts, we should output them as a chunk
|
|
@@ -731,6 +749,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
|
731
749
|
if self.function_args_buffer:
|
|
732
750
|
# In this case, we should release the buffer + new data at once
|
|
733
751
|
combined_chunk = self.function_args_buffer + updates_main_json
|
|
752
|
+
|
|
734
753
|
processed_chunk = AssistantMessage(
|
|
735
754
|
id=message_id,
|
|
736
755
|
date=message_date,
|
|
@@ -745,11 +764,24 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
|
745
764
|
|
|
746
765
|
else:
|
|
747
766
|
# If there's no buffer to clear, just output a new chunk with new data
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
)
|
|
767
|
+
# TODO: THIS IS HORRIBLE
|
|
768
|
+
# TODO: WE USE THE OLD JSON PARSER EARLIER (WHICH DOES NOTHING) AND NOW THE NEW JSON PARSER
|
|
769
|
+
# TODO: THIS IS TOTALLY WRONG AND BAD, BUT SAVING FOR A LARGER REWRITE IN THE NEAR FUTURE
|
|
770
|
+
combined_args = "".join(self.current_function_arguments)
|
|
771
|
+
parsed_args = self.optimistic_json_parser.parse(combined_args)
|
|
772
|
+
|
|
773
|
+
if parsed_args.get(self.assistant_message_tool_kwarg) and parsed_args.get(
|
|
774
|
+
self.assistant_message_tool_kwarg
|
|
775
|
+
) != self.current_json_parse_result.get(self.assistant_message_tool_kwarg):
|
|
776
|
+
new_content = parsed_args.get(self.assistant_message_tool_kwarg)
|
|
777
|
+
prev_content = self.current_json_parse_result.get(self.assistant_message_tool_kwarg, "")
|
|
778
|
+
# TODO: Assumes consistent state and that prev_content is subset of new_content
|
|
779
|
+
diff = new_content.replace(prev_content, "", 1)
|
|
780
|
+
self.current_json_parse_result = parsed_args
|
|
781
|
+
processed_chunk = AssistantMessage(id=message_id, date=message_date, content=diff)
|
|
782
|
+
else:
|
|
783
|
+
return None
|
|
784
|
+
|
|
753
785
|
# Store the ID of the tool call so allow skipping the corresponding response
|
|
754
786
|
if self.function_id_buffer:
|
|
755
787
|
self.prev_assistant_message_id = self.function_id_buffer
|
|
@@ -1018,6 +1050,7 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
|
1018
1050
|
message_date=message_date,
|
|
1019
1051
|
expect_reasoning_content=expect_reasoning_content,
|
|
1020
1052
|
)
|
|
1053
|
+
|
|
1021
1054
|
if processed_chunk is None:
|
|
1022
1055
|
return
|
|
1023
1056
|
|
|
@@ -53,7 +53,7 @@ def list_agents(
|
|
|
53
53
|
project_id: Optional[str] = Query(None, description="Search agents by project id"),
|
|
54
54
|
template_id: Optional[str] = Query(None, description="Search agents by template id"),
|
|
55
55
|
base_template_id: Optional[str] = Query(None, description="Search agents by base template id"),
|
|
56
|
-
|
|
56
|
+
identity_id: Optional[str] = Query(None, description="Search agents by identifier id"),
|
|
57
57
|
identifier_keys: Optional[List[str]] = Query(None, description="Search agents by identifier keys"),
|
|
58
58
|
):
|
|
59
59
|
"""
|
|
@@ -84,7 +84,7 @@ def list_agents(
|
|
|
84
84
|
tags=tags,
|
|
85
85
|
match_all_tags=match_all_tags,
|
|
86
86
|
identifier_keys=identifier_keys,
|
|
87
|
-
|
|
87
|
+
identity_id=identity_id,
|
|
88
88
|
**kwargs,
|
|
89
89
|
)
|
|
90
90
|
return agents
|
|
@@ -20,11 +20,15 @@ def list_blocks(
|
|
|
20
20
|
label: Optional[str] = Query(None, description="Labels to include (e.g. human, persona)"),
|
|
21
21
|
templates_only: bool = Query(True, description="Whether to include only templates"),
|
|
22
22
|
name: Optional[str] = Query(None, description="Name of the block"),
|
|
23
|
+
identity_id: Optional[str] = Query(None, description="Search agents by identifier id"),
|
|
24
|
+
identifier_keys: Optional[List[str]] = Query(None, description="Search agents by identifier keys"),
|
|
23
25
|
server: SyncServer = Depends(get_letta_server),
|
|
24
26
|
actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
|
|
25
27
|
):
|
|
26
28
|
actor = server.user_manager.get_user_or_default(user_id=actor_id)
|
|
27
|
-
return server.block_manager.get_blocks(
|
|
29
|
+
return server.block_manager.get_blocks(
|
|
30
|
+
actor=actor, label=label, is_template=templates_only, template_name=name, identity_id=identity_id, identifier_keys=identifier_keys
|
|
31
|
+
)
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
@router.post("/", response_model=Block, operation_id="create_block")
|