letta-nightly 0.7.29.dev20250602104315__py3-none-any.whl → 0.8.0.dev20250604104349__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 (138) hide show
  1. letta/__init__.py +7 -1
  2. letta/agent.py +16 -9
  3. letta/agents/base_agent.py +1 -0
  4. letta/agents/ephemeral_summary_agent.py +104 -0
  5. letta/agents/helpers.py +35 -3
  6. letta/agents/letta_agent.py +492 -176
  7. letta/agents/letta_agent_batch.py +22 -16
  8. letta/agents/prompts/summary_system_prompt.txt +62 -0
  9. letta/agents/voice_agent.py +22 -7
  10. letta/agents/voice_sleeptime_agent.py +13 -8
  11. letta/constants.py +33 -1
  12. letta/data_sources/connectors.py +52 -36
  13. letta/errors.py +4 -0
  14. letta/functions/ast_parsers.py +13 -30
  15. letta/functions/function_sets/base.py +3 -1
  16. letta/functions/functions.py +2 -0
  17. letta/functions/mcp_client/base_client.py +151 -97
  18. letta/functions/mcp_client/sse_client.py +49 -31
  19. letta/functions/mcp_client/stdio_client.py +107 -106
  20. letta/functions/schema_generator.py +22 -22
  21. letta/groups/helpers.py +3 -4
  22. letta/groups/sleeptime_multi_agent.py +4 -4
  23. letta/groups/sleeptime_multi_agent_v2.py +22 -0
  24. letta/helpers/composio_helpers.py +16 -0
  25. letta/helpers/converters.py +20 -0
  26. letta/helpers/datetime_helpers.py +1 -6
  27. letta/helpers/tool_rule_solver.py +2 -1
  28. letta/interfaces/anthropic_streaming_interface.py +17 -2
  29. letta/interfaces/openai_chat_completions_streaming_interface.py +1 -0
  30. letta/interfaces/openai_streaming_interface.py +18 -2
  31. letta/jobs/llm_batch_job_polling.py +1 -1
  32. letta/jobs/scheduler.py +1 -1
  33. letta/llm_api/anthropic_client.py +24 -3
  34. letta/llm_api/google_ai_client.py +0 -15
  35. letta/llm_api/google_vertex_client.py +6 -5
  36. letta/llm_api/llm_client_base.py +15 -0
  37. letta/llm_api/openai.py +2 -2
  38. letta/llm_api/openai_client.py +60 -8
  39. letta/orm/__init__.py +2 -0
  40. letta/orm/agent.py +45 -43
  41. letta/orm/base.py +0 -2
  42. letta/orm/block.py +1 -0
  43. letta/orm/custom_columns.py +13 -0
  44. letta/orm/enums.py +5 -0
  45. letta/orm/file.py +3 -1
  46. letta/orm/files_agents.py +68 -0
  47. letta/orm/mcp_server.py +48 -0
  48. letta/orm/message.py +1 -0
  49. letta/orm/organization.py +11 -2
  50. letta/orm/passage.py +25 -10
  51. letta/orm/sandbox_config.py +5 -2
  52. letta/orm/sqlalchemy_base.py +171 -110
  53. letta/prompts/system/memgpt_base.txt +6 -1
  54. letta/prompts/system/memgpt_v2_chat.txt +57 -0
  55. letta/prompts/system/sleeptime.txt +2 -0
  56. letta/prompts/system/sleeptime_v2.txt +28 -0
  57. letta/schemas/agent.py +87 -20
  58. letta/schemas/block.py +7 -1
  59. letta/schemas/file.py +57 -0
  60. letta/schemas/mcp.py +74 -0
  61. letta/schemas/memory.py +5 -2
  62. letta/schemas/message.py +9 -0
  63. letta/schemas/openai/openai.py +0 -6
  64. letta/schemas/providers.py +33 -4
  65. letta/schemas/tool.py +26 -21
  66. letta/schemas/tool_execution_result.py +5 -0
  67. letta/server/db.py +23 -8
  68. letta/server/rest_api/app.py +73 -56
  69. letta/server/rest_api/interface.py +4 -4
  70. letta/server/rest_api/routers/v1/agents.py +132 -47
  71. letta/server/rest_api/routers/v1/blocks.py +3 -2
  72. letta/server/rest_api/routers/v1/embeddings.py +3 -3
  73. letta/server/rest_api/routers/v1/groups.py +3 -3
  74. letta/server/rest_api/routers/v1/jobs.py +14 -17
  75. letta/server/rest_api/routers/v1/organizations.py +10 -10
  76. letta/server/rest_api/routers/v1/providers.py +12 -10
  77. letta/server/rest_api/routers/v1/runs.py +3 -3
  78. letta/server/rest_api/routers/v1/sandbox_configs.py +12 -12
  79. letta/server/rest_api/routers/v1/sources.py +108 -43
  80. letta/server/rest_api/routers/v1/steps.py +8 -6
  81. letta/server/rest_api/routers/v1/tools.py +134 -95
  82. letta/server/rest_api/utils.py +12 -1
  83. letta/server/server.py +272 -73
  84. letta/services/agent_manager.py +246 -313
  85. letta/services/block_manager.py +30 -9
  86. letta/services/context_window_calculator/__init__.py +0 -0
  87. letta/services/context_window_calculator/context_window_calculator.py +150 -0
  88. letta/services/context_window_calculator/token_counter.py +82 -0
  89. letta/services/file_processor/__init__.py +0 -0
  90. letta/services/file_processor/chunker/__init__.py +0 -0
  91. letta/services/file_processor/chunker/llama_index_chunker.py +29 -0
  92. letta/services/file_processor/embedder/__init__.py +0 -0
  93. letta/services/file_processor/embedder/openai_embedder.py +84 -0
  94. letta/services/file_processor/file_processor.py +123 -0
  95. letta/services/file_processor/parser/__init__.py +0 -0
  96. letta/services/file_processor/parser/base_parser.py +9 -0
  97. letta/services/file_processor/parser/mistral_parser.py +54 -0
  98. letta/services/file_processor/types.py +0 -0
  99. letta/services/files_agents_manager.py +184 -0
  100. letta/services/group_manager.py +118 -0
  101. letta/services/helpers/agent_manager_helper.py +76 -21
  102. letta/services/helpers/tool_execution_helper.py +3 -0
  103. letta/services/helpers/tool_parser_helper.py +100 -0
  104. letta/services/identity_manager.py +44 -42
  105. letta/services/job_manager.py +21 -10
  106. letta/services/mcp/base_client.py +5 -2
  107. letta/services/mcp/sse_client.py +3 -5
  108. letta/services/mcp/stdio_client.py +3 -5
  109. letta/services/mcp_manager.py +281 -0
  110. letta/services/message_manager.py +40 -26
  111. letta/services/organization_manager.py +55 -19
  112. letta/services/passage_manager.py +211 -13
  113. letta/services/provider_manager.py +48 -2
  114. letta/services/sandbox_config_manager.py +105 -0
  115. letta/services/source_manager.py +4 -5
  116. letta/services/step_manager.py +9 -6
  117. letta/services/summarizer/summarizer.py +50 -23
  118. letta/services/telemetry_manager.py +7 -0
  119. letta/services/tool_executor/tool_execution_manager.py +11 -52
  120. letta/services/tool_executor/tool_execution_sandbox.py +4 -34
  121. letta/services/tool_executor/tool_executor.py +107 -105
  122. letta/services/tool_manager.py +56 -17
  123. letta/services/tool_sandbox/base.py +39 -92
  124. letta/services/tool_sandbox/e2b_sandbox.py +16 -11
  125. letta/services/tool_sandbox/local_sandbox.py +51 -23
  126. letta/services/user_manager.py +36 -3
  127. letta/settings.py +10 -3
  128. letta/templates/__init__.py +0 -0
  129. letta/templates/sandbox_code_file.py.j2 +47 -0
  130. letta/templates/template_helper.py +16 -0
  131. letta/tracing.py +30 -1
  132. letta/types/__init__.py +7 -0
  133. letta/utils.py +25 -1
  134. {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/METADATA +7 -2
  135. {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/RECORD +138 -112
  136. {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/LICENSE +0 -0
  137. {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/WHEEL +0 -0
  138. {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/entry_points.txt +0 -0
@@ -1,108 +1,109 @@
1
- import asyncio
2
- import sys
3
- from contextlib import asynccontextmanager
1
+ # import asyncio
2
+ # import sys
3
+ # from contextlib import asynccontextmanager
4
+ #
5
+ # import anyio
6
+ # import anyio.lowlevel
7
+ # import mcp.types as types
8
+ # from anyio.streams.text import TextReceiveStream
9
+ # from mcp import ClientSession, StdioServerParameters
10
+ # from mcp.client.stdio import get_default_environment
11
+ #
12
+ # from letta.functions.mcp_client.base_client import BaseMCPClient
13
+ # from letta.functions.mcp_client.types import StdioServerConfig
14
+ # from letta.log import get_logger
15
+ #
16
+ # logger = get_logger(__name__)
4
17
 
5
- import anyio
6
- import anyio.lowlevel
7
- import mcp.types as types
8
- from anyio.streams.text import TextReceiveStream
9
- from mcp import ClientSession, StdioServerParameters
10
- from mcp.client.stdio import get_default_environment
11
18
 
12
- from letta.functions.mcp_client.base_client import BaseMCPClient
13
- from letta.functions.mcp_client.types import StdioServerConfig
14
- from letta.log import get_logger
15
-
16
- logger = get_logger(__name__)
17
-
18
-
19
- class StdioMCPClient(BaseMCPClient):
20
- def _initialize_connection(self, server_config: StdioServerConfig, timeout: float) -> bool:
21
- try:
22
- server_params = StdioServerParameters(command=server_config.command, args=server_config.args, env=server_config.env)
23
- stdio_cm = forked_stdio_client(server_params)
24
- stdio_transport = self.loop.run_until_complete(asyncio.wait_for(stdio_cm.__aenter__(), timeout=timeout))
25
- self.stdio, self.write = stdio_transport
26
- self.cleanup_funcs.append(lambda: self.loop.run_until_complete(stdio_cm.__aexit__(None, None, None)))
27
-
28
- session_cm = ClientSession(self.stdio, self.write)
29
- self.session = self.loop.run_until_complete(asyncio.wait_for(session_cm.__aenter__(), timeout=timeout))
30
- self.cleanup_funcs.append(lambda: self.loop.run_until_complete(session_cm.__aexit__(None, None, None)))
31
- return True
32
- except asyncio.TimeoutError:
33
- logger.error(f"Timed out while establishing stdio connection (timeout={timeout}s).")
34
- return False
35
- except Exception:
36
- logger.exception("Exception occurred while initializing stdio client session.")
37
- return False
38
-
39
-
40
- @asynccontextmanager
41
- async def forked_stdio_client(server: StdioServerParameters):
42
- """
43
- Client transport for stdio: this will connect to a server by spawning a
44
- process and communicating with it over stdin/stdout.
45
- """
46
- read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
47
- write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
48
-
49
- try:
50
- process = await anyio.open_process(
51
- [server.command, *server.args],
52
- env=server.env or get_default_environment(),
53
- stderr=sys.stderr, # Consider logging stderr somewhere instead of silencing it
54
- )
55
- except OSError as exc:
56
- raise RuntimeError(f"Failed to spawn process: {server.command} {server.args}") from exc
57
-
58
- async def stdout_reader():
59
- assert process.stdout, "Opened process is missing stdout"
60
- buffer = ""
61
- try:
62
- async with read_stream_writer:
63
- async for chunk in TextReceiveStream(
64
- process.stdout,
65
- encoding=server.encoding,
66
- errors=server.encoding_error_handler,
67
- ):
68
- lines = (buffer + chunk).split("\n")
69
- buffer = lines.pop()
70
- for line in lines:
71
- try:
72
- message = types.JSONRPCMessage.model_validate_json(line)
73
- except Exception as exc:
74
- await read_stream_writer.send(exc)
75
- continue
76
- await read_stream_writer.send(message)
77
- except anyio.ClosedResourceError:
78
- await anyio.lowlevel.checkpoint()
79
-
80
- async def stdin_writer():
81
- assert process.stdin, "Opened process is missing stdin"
82
- try:
83
- async with write_stream_reader:
84
- async for message in write_stream_reader:
85
- json = message.model_dump_json(by_alias=True, exclude_none=True)
86
- await process.stdin.send(
87
- (json + "\n").encode(
88
- encoding=server.encoding,
89
- errors=server.encoding_error_handler,
90
- )
91
- )
92
- except anyio.ClosedResourceError:
93
- await anyio.lowlevel.checkpoint()
94
-
95
- async def watch_process_exit():
96
- returncode = await process.wait()
97
- if returncode != 0:
98
- raise RuntimeError(f"Subprocess exited with code {returncode}. Command: {server.command} {server.args}")
99
-
100
- async with anyio.create_task_group() as tg, process:
101
- tg.start_soon(stdout_reader)
102
- tg.start_soon(stdin_writer)
103
- tg.start_soon(watch_process_exit)
104
-
105
- with anyio.move_on_after(0.2):
106
- await anyio.sleep_forever()
107
-
108
- yield read_stream, write_stream
19
+ # class StdioMCPClient(BaseMCPClient):
20
+ # def _initialize_connection(self, server_config: StdioServerConfig, timeout: float) -> bool:
21
+ # try:
22
+ # server_params = StdioServerParameters(command=server_config.command, args=server_config.args, env=server_config.env)
23
+ # stdio_cm = forked_stdio_client(server_params)
24
+ # stdio_transport = self.loop.run_until_complete(asyncio.wait_for(stdio_cm.__aenter__(), timeout=timeout))
25
+ # self.stdio, self.write = stdio_transport
26
+ # self.cleanup_funcs.append(lambda: self.loop.run_until_complete(stdio_cm.__aexit__(None, None, None)))
27
+ #
28
+ # session_cm = ClientSession(self.stdio, self.write)
29
+ # self.session = self.loop.run_until_complete(asyncio.wait_for(session_cm.__aenter__(), timeout=timeout))
30
+ # self.cleanup_funcs.append(lambda: self.loop.run_until_complete(session_cm.__aexit__(None, None, None)))
31
+ # return True
32
+ # except asyncio.TimeoutError:
33
+ # logger.error(f"Timed out while establishing stdio connection (timeout={timeout}s).")
34
+ # return False
35
+ # except Exception:
36
+ # logger.exception("Exception occurred while initializing stdio client session.")
37
+ # return False
38
+ #
39
+ #
40
+ # @asynccontextmanager
41
+ # async def forked_stdio_client(server: StdioServerParameters):
42
+ # """
43
+ # Client transport for stdio: this will connect to a server by spawning a
44
+ # process and communicating with it over stdin/stdout.
45
+ # """
46
+ # read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
47
+ # write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
48
+ #
49
+ # try:
50
+ # process = await anyio.open_process(
51
+ # [server.command, *server.args],
52
+ # env=server.env or get_default_environment(),
53
+ # stderr=sys.stderr, # Consider logging stderr somewhere instead of silencing it
54
+ # )
55
+ # except OSError as exc:
56
+ # raise RuntimeError(f"Failed to spawn process: {server.command} {server.args}") from exc
57
+ #
58
+ # async def stdout_reader():
59
+ # assert process.stdout, "Opened process is missing stdout"
60
+ # buffer = ""
61
+ # try:
62
+ # async with read_stream_writer:
63
+ # async for chunk in TextReceiveStream(
64
+ # process.stdout,
65
+ # encoding=server.encoding,
66
+ # errors=server.encoding_error_handler,
67
+ # ):
68
+ # lines = (buffer + chunk).split("\n")
69
+ # buffer = lines.pop()
70
+ # for line in lines:
71
+ # try:
72
+ # message = types.JSONRPCMessage.model_validate_json(line)
73
+ # except Exception as exc:
74
+ # await read_stream_writer.send(exc)
75
+ # continue
76
+ # await read_stream_writer.send(message)
77
+ # except anyio.ClosedResourceError:
78
+ # await anyio.lowlevel.checkpoint()
79
+ #
80
+ # async def stdin_writer():
81
+ # assert process.stdin, "Opened process is missing stdin"
82
+ # try:
83
+ # async with write_stream_reader:
84
+ # async for message in write_stream_reader:
85
+ # json = message.model_dump_json(by_alias=True, exclude_none=True)
86
+ # await process.stdin.send(
87
+ # (json + "\n").encode(
88
+ # encoding=server.encoding,
89
+ # errors=server.encoding_error_handler,
90
+ # )
91
+ # )
92
+ # except anyio.ClosedResourceError:
93
+ # await anyio.lowlevel.checkpoint()
94
+ #
95
+ # async def watch_process_exit():
96
+ # returncode = await process.wait()
97
+ # if returncode != 0:
98
+ # raise RuntimeError(f"Subprocess exited with code {returncode}. Command: {server.command} {server.args}")
99
+ #
100
+ # async with anyio.create_task_group() as tg, process:
101
+ # tg.start_soon(stdout_reader)
102
+ # tg.start_soon(stdin_writer)
103
+ # tg.start_soon(watch_process_exit)
104
+ #
105
+ # with anyio.move_on_after(0.2):
106
+ # await anyio.sleep_forever()
107
+ #
108
+ # yield read_stream, write_stream
109
+ #
@@ -7,6 +7,7 @@ from docstring_parser import parse
7
7
  from pydantic import BaseModel
8
8
  from typing_extensions import Literal
9
9
 
10
+ from letta.constants import REQUEST_HEARTBEAT_DESCRIPTION, REQUEST_HEARTBEAT_PARAM
10
11
  from letta.functions.mcp_client.types import MCPTool
11
12
 
12
13
 
@@ -143,7 +144,10 @@ def pydantic_model_to_open_ai(model: Type[BaseModel]) -> dict:
143
144
  parameters["required"] = sorted(k for k, v in parameters["properties"].items() if "default" not in v)
144
145
 
145
146
  if "description" not in schema:
146
- if docstring.short_description:
147
+ # Support multiline docstrings for complex functions, TODO (cliandy): consider having this as a setting
148
+ if docstring.long_description:
149
+ schema["description"] = docstring.long_description
150
+ elif docstring.short_description:
147
151
  schema["description"] = docstring.short_description
148
152
  else:
149
153
  raise ValueError(f"No description found in docstring or description field (model: {model}, docstring: {docstring})")
@@ -330,10 +334,17 @@ def generate_schema(function, name: Optional[str] = None, description: Optional[
330
334
  # Parse the docstring
331
335
  docstring = parse(function.__doc__)
332
336
 
337
+ if not description:
338
+ # Support multiline docstrings for complex functions, TODO (cliandy): consider having this as a setting
339
+ if docstring.long_description:
340
+ description = docstring.long_description
341
+ else:
342
+ description = docstring.short_description
343
+
333
344
  # Prepare the schema dictionary
334
345
  schema = {
335
346
  "name": function.__name__ if name is None else name,
336
- "description": docstring.short_description if description is None else description,
347
+ "description": description,
337
348
  "parameters": {"type": "object", "properties": {}, "required": []},
338
349
  }
339
350
 
@@ -412,17 +423,6 @@ def generate_schema(function, name: Optional[str] = None, description: Optional[
412
423
  # TODO is this not duplicating the other append directly above?
413
424
  if param.annotation == inspect.Parameter.empty:
414
425
  schema["parameters"]["required"].append(param.name)
415
-
416
- # append the heartbeat
417
- # TODO: don't hard-code
418
- # TODO: if terminal, don't include this
419
- # if function.__name__ not in ["send_message"]:
420
- schema["parameters"]["properties"]["request_heartbeat"] = {
421
- "type": "boolean",
422
- "description": "Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function.",
423
- }
424
- schema["parameters"]["required"].append("request_heartbeat")
425
-
426
426
  return schema
427
427
 
428
428
 
@@ -445,11 +445,11 @@ def generate_schema_from_args_schema_v2(
445
445
  }
446
446
 
447
447
  if append_heartbeat:
448
- function_call_json["parameters"]["properties"]["request_heartbeat"] = {
448
+ function_call_json["parameters"]["properties"][REQUEST_HEARTBEAT_PARAM] = {
449
449
  "type": "boolean",
450
- "description": "Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function.",
450
+ "description": REQUEST_HEARTBEAT_DESCRIPTION,
451
451
  }
452
- function_call_json["parameters"]["required"].append("request_heartbeat")
452
+ function_call_json["parameters"]["required"].append(REQUEST_HEARTBEAT_PARAM)
453
453
 
454
454
  return function_call_json
455
455
 
@@ -476,11 +476,11 @@ def generate_tool_schema_for_mcp(
476
476
 
477
477
  # Add the optional heartbeat parameter
478
478
  if append_heartbeat:
479
- parameters_schema["properties"]["request_heartbeat"] = {
479
+ parameters_schema["properties"][REQUEST_HEARTBEAT_PARAM] = {
480
480
  "type": "boolean",
481
- "description": "Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function.",
481
+ "description": REQUEST_HEARTBEAT_DESCRIPTION,
482
482
  }
483
- parameters_schema["required"].append("request_heartbeat")
483
+ parameters_schema["required"].append(REQUEST_HEARTBEAT_PARAM)
484
484
 
485
485
  # Return the final schema
486
486
  if strict:
@@ -538,11 +538,11 @@ def generate_tool_schema_for_composio(
538
538
 
539
539
  # Add the optional heartbeat parameter
540
540
  if append_heartbeat:
541
- properties_json["request_heartbeat"] = {
541
+ properties_json[REQUEST_HEARTBEAT_PARAM] = {
542
542
  "type": "boolean",
543
- "description": "Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function.",
543
+ "description": REQUEST_HEARTBEAT_DESCRIPTION,
544
544
  }
545
- required_fields.append("request_heartbeat")
545
+ required_fields.append(REQUEST_HEARTBEAT_PARAM)
546
546
 
547
547
  # Return the final schema
548
548
  if strict:
letta/groups/helpers.py CHANGED
@@ -2,13 +2,13 @@ import json
2
2
  from typing import Dict, Optional, Union
3
3
 
4
4
  from letta.agent import Agent
5
- from letta.functions.mcp_client.base_client import BaseMCPClient
6
5
  from letta.interface import AgentInterface
7
6
  from letta.orm.group import Group
8
7
  from letta.orm.user import User
9
8
  from letta.schemas.agent import AgentState
10
9
  from letta.schemas.group import ManagerType
11
10
  from letta.schemas.message import Message
11
+ from letta.services.mcp.base_client import AsyncBaseMCPClient
12
12
 
13
13
 
14
14
  def load_multi_agent(
@@ -16,7 +16,7 @@ def load_multi_agent(
16
16
  agent_state: Optional[AgentState],
17
17
  actor: User,
18
18
  interface: Union[AgentInterface, None] = None,
19
- mcp_clients: Optional[Dict[str, BaseMCPClient]] = None,
19
+ mcp_clients: Optional[Dict[str, AsyncBaseMCPClient]] = None,
20
20
  ) -> Agent:
21
21
  if len(group.agent_ids) == 0:
22
22
  raise ValueError("Empty group: group must have at least one agent")
@@ -76,7 +76,6 @@ def load_multi_agent(
76
76
  agent_state=agent_state,
77
77
  interface=interface,
78
78
  user=actor,
79
- mcp_clients=mcp_clients,
80
79
  group_id=group.id,
81
80
  agent_ids=group.agent_ids,
82
81
  description=group.description,
@@ -108,7 +107,7 @@ def stringify_message(message: Message, use_assistant_name: bool = False) -> str
108
107
  elif message.role == "tool":
109
108
  if message.content:
110
109
  content = json.loads(message.content[0].text)
111
- if content["message"] != "None" and content["message"] != None:
110
+ if str(content["message"]) != "None":
112
111
  return f"{assistant_name}: Tool call returned {content['message']}"
113
112
  return None
114
113
  elif message.role == "system":
@@ -1,10 +1,9 @@
1
1
  import asyncio
2
2
  import threading
3
3
  from datetime import datetime, timezone
4
- from typing import Dict, List, Optional
4
+ from typing import List, Optional
5
5
 
6
6
  from letta.agent import Agent, AgentState
7
- from letta.functions.mcp_client.base_client import BaseMCPClient
8
7
  from letta.groups.helpers import stringify_message
9
8
  from letta.interface import AgentInterface
10
9
  from letta.orm import User
@@ -27,7 +26,7 @@ class SleeptimeMultiAgent(Agent):
27
26
  interface: AgentInterface,
28
27
  agent_state: AgentState,
29
28
  user: User,
30
- mcp_clients: Optional[Dict[str, BaseMCPClient]] = None,
29
+ # mcp_clients: Optional[Dict[str, BaseMCPClient]] = None,
31
30
  # custom
32
31
  group_id: str = "",
33
32
  agent_ids: List[str] = [],
@@ -42,7 +41,8 @@ class SleeptimeMultiAgent(Agent):
42
41
  self.group_manager = GroupManager()
43
42
  self.message_manager = MessageManager()
44
43
  self.job_manager = JobManager()
45
- self.mcp_clients = mcp_clients
44
+ # TODO: add back MCP support with new agent loop
45
+ self.mcp_clients = {}
46
46
 
47
47
  def _run_async_in_new_thread(self, coro):
48
48
  """Run an async coroutine in a new thread with its own event loop"""
@@ -21,6 +21,7 @@ from letta.services.message_manager import MessageManager
21
21
  from letta.services.passage_manager import PassageManager
22
22
  from letta.services.step_manager import NoopStepManager, StepManager
23
23
  from letta.services.telemetry_manager import NoopTelemetryManager, TelemetryManager
24
+ from letta.tracing import trace_method
24
25
 
25
26
 
26
27
  class SleeptimeMultiAgentV2(BaseAgent):
@@ -55,11 +56,13 @@ class SleeptimeMultiAgentV2(BaseAgent):
55
56
  assert group.manager_type == ManagerType.sleeptime, f"Expected group manager type to be 'sleeptime', got {group.manager_type}"
56
57
  self.group = group
57
58
 
59
+ @trace_method
58
60
  async def step(
59
61
  self,
60
62
  input_messages: List[MessageCreate],
61
63
  max_steps: int = 10,
62
64
  use_assistant_message: bool = True,
65
+ request_start_timestamp_ns: Optional[int] = None,
63
66
  ) -> LettaResponse:
64
67
  run_ids = []
65
68
 
@@ -119,6 +122,22 @@ class SleeptimeMultiAgentV2(BaseAgent):
119
122
  response.usage.run_ids = run_ids
120
123
  return response
121
124
 
125
+ @trace_method
126
+ async def step_stream_no_tokens(
127
+ self,
128
+ input_messages: List[MessageCreate],
129
+ max_steps: int = 10,
130
+ use_assistant_message: bool = True,
131
+ request_start_timestamp_ns: Optional[int] = None,
132
+ ):
133
+ response = await self.step(input_messages, max_steps, use_assistant_message)
134
+
135
+ for message in response.messages:
136
+ yield f"data: {message.model_dump_json()}\n\n"
137
+
138
+ yield f"data: {response.usage.model_dump_json()}\n\n"
139
+
140
+ @trace_method
122
141
  async def step_stream(
123
142
  self,
124
143
  input_messages: List[MessageCreate],
@@ -256,6 +275,9 @@ class SleeptimeMultiAgentV2(BaseAgent):
256
275
  actor=self.actor,
257
276
  step_manager=self.step_manager,
258
277
  telemetry_manager=self.telemetry_manager,
278
+ message_buffer_limit=20, # TODO: Make this configurable
279
+ message_buffer_min=8, # TODO: Make this configurable
280
+ enable_summarization=False, # TODO: Make this configurable
259
281
  )
260
282
 
261
283
  # Perform sleeptime agent step
@@ -20,3 +20,19 @@ def get_composio_api_key(actor: User, logger: Optional[Logger] = None) -> Option
20
20
  # Ideally, not tied to a specific sandbox, but for now we just get the first one
21
21
  # Theoretically possible for someone to have different composio api keys per sandbox
22
22
  return api_keys[0].value
23
+
24
+
25
+ async def get_composio_api_key_async(actor: User, logger: Optional[Logger] = None) -> Optional[str]:
26
+ api_keys = await SandboxConfigManager().list_sandbox_env_vars_by_key_async(key="COMPOSIO_API_KEY", actor=actor)
27
+ if not api_keys:
28
+ if logger:
29
+ logger.debug(f"No API keys found for Composio. Defaulting to the environment variable...")
30
+ if tool_settings.composio_api_key:
31
+ return tool_settings.composio_api_key
32
+ else:
33
+ return None
34
+ else:
35
+ # TODO: Add more protections around this
36
+ # Ideally, not tied to a specific sandbox, but for now we just get the first one
37
+ # Theoretically possible for someone to have different composio api keys per sandbox
38
+ return api_keys[0].value
@@ -7,6 +7,7 @@ from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMe
7
7
  from openai.types.chat.chat_completion_message_tool_call import Function as OpenAIFunction
8
8
  from sqlalchemy import Dialect
9
9
 
10
+ from letta.functions.mcp_client.types import StdioServerConfig
10
11
  from letta.schemas.agent import AgentStepState
11
12
  from letta.schemas.embedding_config import EmbeddingConfig
12
13
  from letta.schemas.enums import ProviderType, ToolRuleType
@@ -400,3 +401,22 @@ def deserialize_response_format(data: Optional[Dict]) -> Optional[ResponseFormat
400
401
  return JsonSchemaResponseFormat(**data)
401
402
  if data["type"] == ResponseFormatType.json_object:
402
403
  return JsonObjectResponseFormat(**data)
404
+
405
+
406
+ # --------------------------
407
+ # MCP Stdio Server Config Serialization
408
+ # --------------------------
409
+
410
+
411
+ def serialize_mcp_stdio_config(config: Union[Optional[StdioServerConfig], Dict]) -> Optional[Dict]:
412
+ """Convert an StdioServerConfig object into a JSON-serializable dictionary."""
413
+ if config and isinstance(config, StdioServerConfig):
414
+ return config.to_dict()
415
+ return config
416
+
417
+
418
+ def deserialize_mcp_stdio_config(data: Optional[Dict]) -> Optional[StdioServerConfig]:
419
+ """Convert a dictionary back into an StdioServerConfig object."""
420
+ if not data:
421
+ return None
422
+ return StdioServerConfig(**data)
@@ -16,11 +16,6 @@ def datetime_to_timestamp(dt):
16
16
  return int(dt.timestamp())
17
17
 
18
18
 
19
- def timestamp_to_datetime(ts):
20
- # convert integer timestamp to datetime object
21
- return datetime.fromtimestamp(ts)
22
-
23
-
24
19
  def get_local_time_military():
25
20
  # Get the current time in UTC
26
21
  current_time_utc = datetime.now(pytz.utc)
@@ -36,7 +31,7 @@ def get_local_time_military():
36
31
 
37
32
 
38
33
  def get_local_time_fast():
39
- formatted_time = strftime("%Y-%m-%d %H:%M:%S")
34
+ formatted_time = strftime("%Y-%m-%d %I:%M:%S %p %Z%z")
40
35
 
41
36
  return formatted_time
42
37
 
@@ -141,7 +141,8 @@ class ToolRulesSolver(BaseModel):
141
141
  """Check if the tool is defined as a continue tool in the tool rules."""
142
142
  return any(rule.tool_name == tool_name for rule in self.continue_tool_rules)
143
143
 
144
- def validate_conditional_tool(self, rule: ConditionalToolRule):
144
+ @staticmethod
145
+ def validate_conditional_tool(rule: ConditionalToolRule):
145
146
  """
146
147
  Validate a conditional tool rule
147
148
 
@@ -1,7 +1,7 @@
1
1
  import json
2
2
  from datetime import datetime, timezone
3
3
  from enum import Enum
4
- from typing import AsyncGenerator, List, Union
4
+ from typing import AsyncGenerator, List, Optional, Union
5
5
 
6
6
  from anthropic import AsyncStream
7
7
  from anthropic.types.beta import (
@@ -23,6 +23,7 @@ from anthropic.types.beta import (
23
23
  )
24
24
 
25
25
  from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
26
+ from letta.helpers.datetime_helpers import get_utc_timestamp_ns
26
27
  from letta.local_llm.constants import INNER_THOUGHTS_KWARG
27
28
  from letta.log import get_logger
28
29
  from letta.schemas.letta_message import (
@@ -115,12 +116,26 @@ class AnthropicStreamingInterface:
115
116
  logger.error("Error checking inner thoughts: %s", e)
116
117
  raise
117
118
 
118
- async def process(self, stream: AsyncStream[BetaRawMessageStreamEvent]) -> AsyncGenerator[LettaMessage, None]:
119
+ async def process(
120
+ self,
121
+ stream: AsyncStream[BetaRawMessageStreamEvent],
122
+ ttft_span: Optional["Span"] = None,
123
+ provider_request_start_timestamp_ns: Optional[int] = None,
124
+ ) -> AsyncGenerator[LettaMessage, None]:
119
125
  prev_message_type = None
120
126
  message_index = 0
127
+ first_chunk = True
121
128
  try:
122
129
  async with stream:
123
130
  async for event in stream:
131
+ if first_chunk and ttft_span is not None and provider_request_start_timestamp_ns is not None:
132
+ now = get_utc_timestamp_ns()
133
+ ttft_ns = now - provider_request_start_timestamp_ns
134
+ ttft_span.add_event(
135
+ name="anthropic_time_to_first_token_ms", attributes={"anthropic_time_to_first_token_ms": ttft_ns // 1_000_000}
136
+ )
137
+ first_chunk = False
138
+
124
139
  # TODO: Support BetaThinkingBlock, BetaRedactedThinkingBlock
125
140
  if isinstance(event, BetaRawContentBlockStartEvent):
126
141
  content = event.content_block
@@ -16,6 +16,7 @@ class OpenAIChatCompletionsStreamingInterface:
16
16
  """
17
17
 
18
18
  def __init__(self, stream_pre_execution_message: bool = True):
19
+ print("CHAT COMPLETITION INTERFACE")
19
20
  self.optimistic_json_parser: OptimisticJSONParser = OptimisticJSONParser()
20
21
  self.stream_pre_execution_message: bool = stream_pre_execution_message
21
22
 
@@ -5,6 +5,7 @@ from openai import AsyncStream
5
5
  from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
6
6
 
7
7
  from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
8
+ from letta.helpers.datetime_helpers import get_utc_timestamp_ns
8
9
  from letta.schemas.letta_message import AssistantMessage, LettaMessage, ReasoningMessage, ToolCallDelta, ToolCallMessage
9
10
  from letta.schemas.letta_message_content import TextContent
10
11
  from letta.schemas.message import Message
@@ -26,7 +27,7 @@ class OpenAIStreamingInterface:
26
27
  self.assistant_message_tool_kwarg = DEFAULT_MESSAGE_TOOL_KWARG
27
28
 
28
29
  self.optimistic_json_parser: OptimisticJSONParser = OptimisticJSONParser()
29
- self.function_args_reader = JSONInnerThoughtsExtractor(wait_for_first_key=True) # TODO: pass in kward
30
+ self.function_args_reader = JSONInnerThoughtsExtractor(wait_for_first_key=True) # TODO: pass in kwarg
30
31
  self.function_name_buffer = None
31
32
  self.function_args_buffer = None
32
33
  self.function_id_buffer = None
@@ -64,15 +65,30 @@ class OpenAIStreamingInterface:
64
65
  function=FunctionCall(arguments=self.current_function_arguments, name=function_name),
65
66
  )
66
67
 
67
- async def process(self, stream: AsyncStream[ChatCompletionChunk]) -> AsyncGenerator[LettaMessage, None]:
68
+ async def process(
69
+ self,
70
+ stream: AsyncStream[ChatCompletionChunk],
71
+ ttft_span: Optional["Span"] = None,
72
+ provider_request_start_timestamp_ns: Optional[int] = None,
73
+ ) -> AsyncGenerator[LettaMessage, None]:
68
74
  """
69
75
  Iterates over the OpenAI stream, yielding SSE events.
70
76
  It also collects tokens and detects if a tool call is triggered.
71
77
  """
78
+ first_chunk = True
79
+
72
80
  async with stream:
73
81
  prev_message_type = None
74
82
  message_index = 0
75
83
  async for chunk in stream:
84
+ if first_chunk and ttft_span is not None and provider_request_start_timestamp_ns is not None:
85
+ now = get_utc_timestamp_ns()
86
+ ttft_ns = now - provider_request_start_timestamp_ns
87
+ ttft_span.add_event(
88
+ name="openai_time_to_first_token_ms", attributes={"openai_time_to_first_token_ms": ttft_ns // 1_000_000}
89
+ )
90
+ first_chunk = False
91
+
76
92
  if not self.model or not self.message_id:
77
93
  self.model = chunk.model
78
94
  self.message_id = chunk.id
@@ -238,4 +238,4 @@ async def poll_running_llm_batches(server: "SyncServer") -> List[LettaBatchRespo
238
238
  logger.exception("[Poll BatchJob] Unhandled error in poll_running_llm_batches", exc_info=e)
239
239
  finally:
240
240
  # 7. Log metrics summary
241
- metrics.log_summary()
241
+ metrics.log_summary()
letta/jobs/scheduler.py CHANGED
@@ -252,4 +252,4 @@ async def shutdown_scheduler_and_release_lock():
252
252
  try:
253
253
  scheduler.shutdown(wait=False)
254
254
  except:
255
- pass
255
+ pass