letta-nightly 0.6.39.dev20250314104053__py3-none-any.whl → 0.6.40.dev20250314222759__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 +14 -4
- letta/agents/ephemeral_agent.py +2 -1
- letta/agents/low_latency_agent.py +8 -0
- letta/dynamic_multi_agent.py +274 -0
- letta/functions/function_sets/base.py +1 -0
- letta/functions/function_sets/extras.py +2 -1
- letta/functions/function_sets/multi_agent.py +17 -0
- letta/functions/helpers.py +41 -0
- letta/functions/mcp_client/__init__.py +0 -0
- letta/functions/mcp_client/base_client.py +61 -0
- letta/functions/mcp_client/sse_client.py +21 -0
- letta/functions/mcp_client/stdio_client.py +103 -0
- letta/functions/mcp_client/types.py +48 -0
- letta/functions/schema_generator.py +1 -1
- letta/helpers/converters.py +67 -0
- letta/llm_api/openai.py +1 -1
- letta/memory.py +2 -1
- letta/orm/__init__.py +2 -0
- letta/orm/agent.py +69 -20
- letta/orm/custom_columns.py +15 -0
- letta/orm/group.py +33 -0
- letta/orm/groups_agents.py +13 -0
- letta/orm/message.py +7 -4
- letta/orm/organization.py +1 -0
- letta/orm/sqlalchemy_base.py +3 -3
- letta/round_robin_multi_agent.py +152 -0
- letta/schemas/agent.py +3 -0
- letta/schemas/enums.py +0 -4
- letta/schemas/group.py +65 -0
- letta/schemas/letta_message.py +167 -106
- letta/schemas/letta_message_content.py +192 -0
- letta/schemas/message.py +28 -36
- letta/schemas/tool.py +1 -1
- letta/serialize_schemas/__init__.py +1 -1
- letta/serialize_schemas/marshmallow_agent.py +108 -0
- letta/serialize_schemas/{agent_environment_variable.py → marshmallow_agent_environment_variable.py} +1 -1
- letta/serialize_schemas/marshmallow_base.py +52 -0
- letta/serialize_schemas/{block.py → marshmallow_block.py} +1 -1
- letta/serialize_schemas/{custom_fields.py → marshmallow_custom_fields.py} +12 -0
- letta/serialize_schemas/marshmallow_message.py +42 -0
- letta/serialize_schemas/{tag.py → marshmallow_tag.py} +12 -2
- letta/serialize_schemas/{tool.py → marshmallow_tool.py} +1 -1
- letta/serialize_schemas/pydantic_agent_schema.py +111 -0
- letta/server/rest_api/app.py +15 -0
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +46 -40
- letta/server/rest_api/routers/v1/groups.py +233 -0
- letta/server/rest_api/routers/v1/tools.py +31 -3
- letta/server/rest_api/utils.py +1 -1
- letta/server/server.py +272 -22
- letta/services/agent_manager.py +65 -28
- letta/services/group_manager.py +147 -0
- letta/services/helpers/agent_manager_helper.py +151 -1
- letta/services/message_manager.py +11 -3
- letta/services/passage_manager.py +15 -0
- letta/settings.py +5 -0
- letta/supervisor_multi_agent.py +103 -0
- {letta_nightly-0.6.39.dev20250314104053.dist-info → letta_nightly-0.6.40.dev20250314222759.dist-info}/METADATA +1 -2
- {letta_nightly-0.6.39.dev20250314104053.dist-info → letta_nightly-0.6.40.dev20250314222759.dist-info}/RECORD +63 -49
- letta/helpers/mcp_helpers.py +0 -108
- letta/serialize_schemas/agent.py +0 -80
- letta/serialize_schemas/base.py +0 -64
- letta/serialize_schemas/message.py +0 -29
- {letta_nightly-0.6.39.dev20250314104053.dist-info → letta_nightly-0.6.40.dev20250314222759.dist-info}/LICENSE +0 -0
- {letta_nightly-0.6.39.dev20250314104053.dist-info → letta_nightly-0.6.40.dev20250314222759.dist-info}/WHEEL +0 -0
- {letta_nightly-0.6.39.dev20250314104053.dist-info → letta_nightly-0.6.40.dev20250314222759.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
|
|
4
|
+
import anyio
|
|
5
|
+
import anyio.lowlevel
|
|
6
|
+
import mcp.types as types
|
|
7
|
+
from anyio.streams.text import TextReceiveStream
|
|
8
|
+
from mcp import ClientSession, StdioServerParameters
|
|
9
|
+
from mcp.client.stdio import get_default_environment
|
|
10
|
+
|
|
11
|
+
from letta.functions.mcp_client.base_client import BaseMCPClient
|
|
12
|
+
from letta.functions.mcp_client.types import StdioServerConfig
|
|
13
|
+
from letta.log import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StdioMCPClient(BaseMCPClient):
|
|
19
|
+
def _initialize_connection(self, server_config: StdioServerConfig) -> bool:
|
|
20
|
+
try:
|
|
21
|
+
server_params = StdioServerParameters(command=server_config.command, args=server_config.args)
|
|
22
|
+
stdio_cm = forked_stdio_client(server_params)
|
|
23
|
+
stdio_transport = self.loop.run_until_complete(stdio_cm.__aenter__())
|
|
24
|
+
self.stdio, self.write = stdio_transport
|
|
25
|
+
self.cleanup_funcs.append(lambda: self.loop.run_until_complete(stdio_cm.__aexit__(None, None, None)))
|
|
26
|
+
|
|
27
|
+
session_cm = ClientSession(self.stdio, self.write)
|
|
28
|
+
self.session = self.loop.run_until_complete(session_cm.__aenter__())
|
|
29
|
+
self.cleanup_funcs.append(lambda: self.loop.run_until_complete(session_cm.__aexit__(None, None, None)))
|
|
30
|
+
return True
|
|
31
|
+
except Exception:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@asynccontextmanager
|
|
36
|
+
async def forked_stdio_client(server: StdioServerParameters):
|
|
37
|
+
"""
|
|
38
|
+
Client transport for stdio: this will connect to a server by spawning a
|
|
39
|
+
process and communicating with it over stdin/stdout.
|
|
40
|
+
"""
|
|
41
|
+
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
|
|
42
|
+
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
process = await anyio.open_process(
|
|
46
|
+
[server.command, *server.args],
|
|
47
|
+
env=server.env or get_default_environment(),
|
|
48
|
+
stderr=sys.stderr, # Consider logging stderr somewhere instead of silencing it
|
|
49
|
+
)
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
raise RuntimeError(f"Failed to spawn process: {server.command} {server.args}") from exc
|
|
52
|
+
|
|
53
|
+
async def stdout_reader():
|
|
54
|
+
assert process.stdout, "Opened process is missing stdout"
|
|
55
|
+
buffer = ""
|
|
56
|
+
try:
|
|
57
|
+
async with read_stream_writer:
|
|
58
|
+
async for chunk in TextReceiveStream(
|
|
59
|
+
process.stdout,
|
|
60
|
+
encoding=server.encoding,
|
|
61
|
+
errors=server.encoding_error_handler,
|
|
62
|
+
):
|
|
63
|
+
lines = (buffer + chunk).split("\n")
|
|
64
|
+
buffer = lines.pop()
|
|
65
|
+
for line in lines:
|
|
66
|
+
try:
|
|
67
|
+
message = types.JSONRPCMessage.model_validate_json(line)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
await read_stream_writer.send(exc)
|
|
70
|
+
continue
|
|
71
|
+
await read_stream_writer.send(message)
|
|
72
|
+
except anyio.ClosedResourceError:
|
|
73
|
+
await anyio.lowlevel.checkpoint()
|
|
74
|
+
|
|
75
|
+
async def stdin_writer():
|
|
76
|
+
assert process.stdin, "Opened process is missing stdin"
|
|
77
|
+
try:
|
|
78
|
+
async with write_stream_reader:
|
|
79
|
+
async for message in write_stream_reader:
|
|
80
|
+
json = message.model_dump_json(by_alias=True, exclude_none=True)
|
|
81
|
+
await process.stdin.send(
|
|
82
|
+
(json + "\n").encode(
|
|
83
|
+
encoding=server.encoding,
|
|
84
|
+
errors=server.encoding_error_handler,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
except anyio.ClosedResourceError:
|
|
88
|
+
await anyio.lowlevel.checkpoint()
|
|
89
|
+
|
|
90
|
+
async def watch_process_exit():
|
|
91
|
+
returncode = await process.wait()
|
|
92
|
+
if returncode != 0:
|
|
93
|
+
raise RuntimeError(f"Subprocess exited with code {returncode}. Command: {server.command} {server.args}")
|
|
94
|
+
|
|
95
|
+
async with anyio.create_task_group() as tg, process:
|
|
96
|
+
tg.start_soon(stdout_reader)
|
|
97
|
+
tg.start_soon(stdin_writer)
|
|
98
|
+
tg.start_soon(watch_process_exit)
|
|
99
|
+
|
|
100
|
+
with anyio.move_on_after(0.2):
|
|
101
|
+
await anyio.sleep_forever()
|
|
102
|
+
|
|
103
|
+
yield read_stream, write_stream
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
from mcp import Tool
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MCPTool(Tool):
|
|
9
|
+
"""A simple wrapper around MCP's tool definition (to avoid conflict with our own)"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MCPServerType(str, Enum):
|
|
13
|
+
SSE = "sse"
|
|
14
|
+
STDIO = "stdio"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseServerConfig(BaseModel):
|
|
18
|
+
server_name: str = Field(..., description="The name of the server")
|
|
19
|
+
type: MCPServerType
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SSEServerConfig(BaseServerConfig):
|
|
23
|
+
type: MCPServerType = MCPServerType.SSE
|
|
24
|
+
server_url: str = Field(..., description="The URL of the server (MCP SSE client will connect to this URL)")
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict:
|
|
27
|
+
values = {
|
|
28
|
+
"transport": "sse",
|
|
29
|
+
"url": self.server_url,
|
|
30
|
+
}
|
|
31
|
+
return values
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StdioServerConfig(BaseServerConfig):
|
|
35
|
+
type: MCPServerType = MCPServerType.STDIO
|
|
36
|
+
command: str = Field(..., description="The command to run (MCP 'local' client will run this command)")
|
|
37
|
+
args: List[str] = Field(..., description="The arguments to pass to the command")
|
|
38
|
+
env: Optional[dict[str, str]] = Field(None, description="Environment variables to set")
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
values = {
|
|
42
|
+
"transport": "stdio",
|
|
43
|
+
"command": self.command,
|
|
44
|
+
"args": self.args,
|
|
45
|
+
}
|
|
46
|
+
if self.env is not None:
|
|
47
|
+
values["env"] = self.env
|
|
48
|
+
return values
|
|
@@ -6,7 +6,7 @@ from composio.client.collections import ActionParametersModel
|
|
|
6
6
|
from docstring_parser import parse
|
|
7
7
|
from pydantic import BaseModel
|
|
8
8
|
|
|
9
|
-
from letta.
|
|
9
|
+
from letta.functions.mcp_client.types import MCPTool
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def is_optional(annotation):
|
letta/helpers/converters.py
CHANGED
|
@@ -8,6 +8,16 @@ from sqlalchemy import Dialect
|
|
|
8
8
|
|
|
9
9
|
from letta.schemas.embedding_config import EmbeddingConfig
|
|
10
10
|
from letta.schemas.enums import ToolRuleType
|
|
11
|
+
from letta.schemas.letta_message_content import (
|
|
12
|
+
MessageContent,
|
|
13
|
+
MessageContentType,
|
|
14
|
+
OmittedReasoningContent,
|
|
15
|
+
ReasoningContent,
|
|
16
|
+
RedactedReasoningContent,
|
|
17
|
+
TextContent,
|
|
18
|
+
ToolCallContent,
|
|
19
|
+
ToolReturnContent,
|
|
20
|
+
)
|
|
11
21
|
from letta.schemas.llm_config import LLMConfig
|
|
12
22
|
from letta.schemas.message import ToolReturn
|
|
13
23
|
from letta.schemas.tool_rule import ChildToolRule, ConditionalToolRule, ContinueToolRule, InitToolRule, TerminalToolRule, ToolRule
|
|
@@ -80,10 +90,13 @@ def deserialize_tool_rule(data: Dict) -> Union[ChildToolRule, InitToolRule, Term
|
|
|
80
90
|
rule_type = ToolRuleType(data.get("type"))
|
|
81
91
|
|
|
82
92
|
if rule_type == ToolRuleType.run_first or rule_type == ToolRuleType.InitToolRule:
|
|
93
|
+
data["type"] = ToolRuleType.run_first
|
|
83
94
|
return InitToolRule(**data)
|
|
84
95
|
elif rule_type == ToolRuleType.exit_loop or rule_type == ToolRuleType.TerminalToolRule:
|
|
96
|
+
data["type"] = ToolRuleType.exit_loop
|
|
85
97
|
return TerminalToolRule(**data)
|
|
86
98
|
elif rule_type == ToolRuleType.constrain_child_tools or rule_type == ToolRuleType.ToolRule:
|
|
99
|
+
data["type"] = ToolRuleType.constrain_child_tools
|
|
87
100
|
return ChildToolRule(**data)
|
|
88
101
|
elif rule_type == ToolRuleType.conditional:
|
|
89
102
|
return ConditionalToolRule(**data)
|
|
@@ -163,6 +176,60 @@ def deserialize_tool_returns(data: Optional[List[Dict]]) -> List[ToolReturn]:
|
|
|
163
176
|
return tool_returns
|
|
164
177
|
|
|
165
178
|
|
|
179
|
+
# ----------------------------
|
|
180
|
+
# MessageContent Serialization
|
|
181
|
+
# ----------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def serialize_message_content(message_content: Optional[List[Union[MessageContent, dict]]]) -> List[Dict]:
|
|
185
|
+
"""Convert a list of MessageContent objects into JSON-serializable format."""
|
|
186
|
+
if not message_content:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
serialized_message_content = []
|
|
190
|
+
for content in message_content:
|
|
191
|
+
if isinstance(content, MessageContent):
|
|
192
|
+
serialized_message_content.append(content.model_dump())
|
|
193
|
+
elif isinstance(content, dict):
|
|
194
|
+
serialized_message_content.append(content) # Already a dictionary, leave it as-is
|
|
195
|
+
else:
|
|
196
|
+
raise TypeError(f"Unexpected message content type: {type(content)}")
|
|
197
|
+
|
|
198
|
+
return serialized_message_content
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def deserialize_message_content(data: Optional[List[Dict]]) -> List[MessageContent]:
|
|
202
|
+
"""Convert a JSON list back into MessageContent objects."""
|
|
203
|
+
if not data:
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
message_content = []
|
|
207
|
+
for item in data:
|
|
208
|
+
if not item:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
content_type = item.get("type")
|
|
212
|
+
if content_type == MessageContentType.text:
|
|
213
|
+
content = TextContent(**item)
|
|
214
|
+
elif content_type == MessageContentType.tool_call:
|
|
215
|
+
content = ToolCallContent(**item)
|
|
216
|
+
elif content_type == MessageContentType.tool_return:
|
|
217
|
+
content = ToolReturnContent(**item)
|
|
218
|
+
elif content_type == MessageContentType.reasoning:
|
|
219
|
+
content = ReasoningContent(**item)
|
|
220
|
+
elif content_type == MessageContentType.redacted_reasoning:
|
|
221
|
+
content = RedactedReasoningContent(**item)
|
|
222
|
+
elif content_type == MessageContentType.omitted_reasoning:
|
|
223
|
+
content = OmittedReasoningContent(**item)
|
|
224
|
+
else:
|
|
225
|
+
# Skip invalid content
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
message_content.append(content)
|
|
229
|
+
|
|
230
|
+
return message_content
|
|
231
|
+
|
|
232
|
+
|
|
166
233
|
# --------------------------
|
|
167
234
|
# Vector Serialization
|
|
168
235
|
# --------------------------
|
letta/llm_api/openai.py
CHANGED
letta/memory.py
CHANGED
|
@@ -5,8 +5,9 @@ from letta.llm_api.llm_api_tools import create
|
|
|
5
5
|
from letta.prompts.gpt_summarize import SYSTEM as SUMMARY_PROMPT_SYSTEM
|
|
6
6
|
from letta.schemas.agent import AgentState
|
|
7
7
|
from letta.schemas.enums import MessageRole
|
|
8
|
+
from letta.schemas.letta_message_content import TextContent
|
|
8
9
|
from letta.schemas.memory import Memory
|
|
9
|
-
from letta.schemas.message import Message
|
|
10
|
+
from letta.schemas.message import Message
|
|
10
11
|
from letta.settings import summarizer_settings
|
|
11
12
|
from letta.utils import count_tokens, printd
|
|
12
13
|
|
letta/orm/__init__.py
CHANGED
|
@@ -4,6 +4,8 @@ from letta.orm.base import Base
|
|
|
4
4
|
from letta.orm.block import Block
|
|
5
5
|
from letta.orm.blocks_agents import BlocksAgents
|
|
6
6
|
from letta.orm.file import FileMetadata
|
|
7
|
+
from letta.orm.group import Group
|
|
8
|
+
from letta.orm.groups_agents import GroupsAgents
|
|
7
9
|
from letta.orm.identities_agents import IdentitiesAgents
|
|
8
10
|
from letta.orm.identities_blocks import IdentitiesBlocks
|
|
9
11
|
from letta.orm.identity import Identity
|
letta/orm/agent.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import uuid
|
|
2
|
-
from typing import TYPE_CHECKING, List, Optional
|
|
2
|
+
from typing import TYPE_CHECKING, List, Optional, Set
|
|
3
3
|
|
|
4
4
|
from sqlalchemy import JSON, Boolean, Index, String
|
|
5
5
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
@@ -128,37 +128,86 @@ class Agent(SqlalchemyBase, OrganizationMixin):
|
|
|
128
128
|
back_populates="agents",
|
|
129
129
|
passive_deletes=True,
|
|
130
130
|
)
|
|
131
|
+
groups: Mapped[List["Group"]] = relationship(
|
|
132
|
+
"Group",
|
|
133
|
+
secondary="groups_agents",
|
|
134
|
+
lazy="selectin",
|
|
135
|
+
back_populates="agents",
|
|
136
|
+
passive_deletes=True,
|
|
137
|
+
)
|
|
138
|
+
multi_agent_group: Mapped["Group"] = relationship(
|
|
139
|
+
"Group",
|
|
140
|
+
lazy="joined",
|
|
141
|
+
viewonly=True,
|
|
142
|
+
back_populates="manager_agent",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def to_pydantic(self, include_relationships: Optional[Set[str]] = None) -> PydanticAgentState:
|
|
146
|
+
"""
|
|
147
|
+
Converts the SQLAlchemy Agent model into its Pydantic counterpart.
|
|
148
|
+
|
|
149
|
+
The following base fields are always included:
|
|
150
|
+
- id, agent_type, name, description, system, message_ids, metadata_,
|
|
151
|
+
llm_config, embedding_config, project_id, template_id, base_template_id,
|
|
152
|
+
tool_rules, message_buffer_autoclear, tags
|
|
131
153
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
Everything else (e.g., tools, sources, memory, etc.) is optional and only
|
|
155
|
+
included if specified in `include_fields`.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
include_relationships (Optional[Set[str]]):
|
|
159
|
+
A set of additional field names to include in the output. If None or empty,
|
|
160
|
+
no extra fields are loaded beyond the base fields.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
PydanticAgentState: The Pydantic representation of the agent.
|
|
164
|
+
"""
|
|
165
|
+
# Base fields: always included
|
|
136
166
|
state = {
|
|
137
167
|
"id": self.id,
|
|
138
|
-
"
|
|
168
|
+
"agent_type": self.agent_type,
|
|
139
169
|
"name": self.name,
|
|
140
170
|
"description": self.description,
|
|
141
|
-
"message_ids": self.message_ids,
|
|
142
|
-
"tools": self.tools,
|
|
143
|
-
"sources": [source.to_pydantic() for source in self.sources],
|
|
144
|
-
"tags": [t.tag for t in self.tags],
|
|
145
|
-
"tool_rules": tool_rules,
|
|
146
171
|
"system": self.system,
|
|
147
|
-
"
|
|
172
|
+
"message_ids": self.message_ids,
|
|
173
|
+
"metadata": self.metadata_, # Exposed as 'metadata' to Pydantic
|
|
148
174
|
"llm_config": self.llm_config,
|
|
149
175
|
"embedding_config": self.embedding_config,
|
|
150
|
-
"metadata": self.metadata_,
|
|
151
|
-
"memory": Memory(blocks=[b.to_pydantic() for b in self.core_memory]),
|
|
152
|
-
"created_by_id": self.created_by_id,
|
|
153
|
-
"last_updated_by_id": self.last_updated_by_id,
|
|
154
|
-
"created_at": self.created_at,
|
|
155
|
-
"updated_at": self.updated_at,
|
|
156
|
-
"tool_exec_environment_variables": self.tool_exec_environment_variables,
|
|
157
176
|
"project_id": self.project_id,
|
|
158
177
|
"template_id": self.template_id,
|
|
159
178
|
"base_template_id": self.base_template_id,
|
|
160
|
-
"
|
|
179
|
+
"tool_rules": self.tool_rules,
|
|
161
180
|
"message_buffer_autoclear": self.message_buffer_autoclear,
|
|
181
|
+
"created_by_id": self.created_by_id,
|
|
182
|
+
"last_updated_by_id": self.last_updated_by_id,
|
|
183
|
+
"created_at": self.created_at,
|
|
184
|
+
"updated_at": self.updated_at,
|
|
185
|
+
# optional field defaults
|
|
186
|
+
"tags": [],
|
|
187
|
+
"tools": [],
|
|
188
|
+
"sources": [],
|
|
189
|
+
"memory": Memory(blocks=[]),
|
|
190
|
+
"identity_ids": [],
|
|
191
|
+
"multi_agent_group": None,
|
|
192
|
+
"tool_exec_environment_variables": [],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# Optional fields: only included if requested
|
|
196
|
+
optional_fields = {
|
|
197
|
+
"tags": lambda: [t.tag for t in self.tags],
|
|
198
|
+
"tools": lambda: self.tools,
|
|
199
|
+
"sources": lambda: [s.to_pydantic() for s in self.sources],
|
|
200
|
+
"memory": lambda: Memory(blocks=[b.to_pydantic() for b in self.core_memory]),
|
|
201
|
+
"identity_ids": lambda: [i.id for i in self.identities],
|
|
202
|
+
"multi_agent_group": lambda: self.multi_agent_group,
|
|
203
|
+
"tool_exec_environment_variables": lambda: self.tool_exec_environment_variables,
|
|
162
204
|
}
|
|
163
205
|
|
|
206
|
+
include_relationships = set(optional_fields.keys() if include_relationships is None else include_relationships)
|
|
207
|
+
|
|
208
|
+
for field_name in include_relationships:
|
|
209
|
+
resolver = optional_fields.get(field_name)
|
|
210
|
+
if resolver:
|
|
211
|
+
state[field_name] = resolver()
|
|
212
|
+
|
|
164
213
|
return self.__pydantic_model__(**state)
|
letta/orm/custom_columns.py
CHANGED
|
@@ -4,12 +4,14 @@ from sqlalchemy.types import BINARY, TypeDecorator
|
|
|
4
4
|
from letta.helpers.converters import (
|
|
5
5
|
deserialize_embedding_config,
|
|
6
6
|
deserialize_llm_config,
|
|
7
|
+
deserialize_message_content,
|
|
7
8
|
deserialize_tool_calls,
|
|
8
9
|
deserialize_tool_returns,
|
|
9
10
|
deserialize_tool_rules,
|
|
10
11
|
deserialize_vector,
|
|
11
12
|
serialize_embedding_config,
|
|
12
13
|
serialize_llm_config,
|
|
14
|
+
serialize_message_content,
|
|
13
15
|
serialize_tool_calls,
|
|
14
16
|
serialize_tool_returns,
|
|
15
17
|
serialize_tool_rules,
|
|
@@ -82,6 +84,19 @@ class ToolReturnColumn(TypeDecorator):
|
|
|
82
84
|
return deserialize_tool_returns(value)
|
|
83
85
|
|
|
84
86
|
|
|
87
|
+
class MessageContentColumn(TypeDecorator):
|
|
88
|
+
"""Custom SQLAlchemy column type for storing the content parts of a message as JSON."""
|
|
89
|
+
|
|
90
|
+
impl = JSON
|
|
91
|
+
cache_ok = True
|
|
92
|
+
|
|
93
|
+
def process_bind_param(self, value, dialect):
|
|
94
|
+
return serialize_message_content(value)
|
|
95
|
+
|
|
96
|
+
def process_result_value(self, value, dialect):
|
|
97
|
+
return deserialize_message_content(value)
|
|
98
|
+
|
|
99
|
+
|
|
85
100
|
class CommonVector(TypeDecorator):
|
|
86
101
|
"""Custom SQLAlchemy column type for storing vectors in SQLite."""
|
|
87
102
|
|
letta/orm/group.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import ForeignKey, String
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
6
|
+
|
|
7
|
+
from letta.orm.mixins import OrganizationMixin
|
|
8
|
+
from letta.orm.sqlalchemy_base import SqlalchemyBase
|
|
9
|
+
from letta.schemas.group import Group as PydanticGroup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Group(SqlalchemyBase, OrganizationMixin):
|
|
13
|
+
|
|
14
|
+
__tablename__ = "groups"
|
|
15
|
+
__pydantic_model__ = PydanticGroup
|
|
16
|
+
|
|
17
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"group-{uuid.uuid4()}")
|
|
18
|
+
description: Mapped[str] = mapped_column(nullable=False, doc="")
|
|
19
|
+
manager_type: Mapped[str] = mapped_column(nullable=False, doc="")
|
|
20
|
+
manager_agent_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("agents.id", ondelete="RESTRICT"), nullable=True, doc="")
|
|
21
|
+
termination_token: Mapped[Optional[str]] = mapped_column(nullable=True, doc="")
|
|
22
|
+
max_turns: Mapped[Optional[int]] = mapped_column(nullable=True, doc="")
|
|
23
|
+
|
|
24
|
+
# relationships
|
|
25
|
+
organization: Mapped["Organization"] = relationship("Organization", back_populates="groups")
|
|
26
|
+
agents: Mapped[List["Agent"]] = relationship(
|
|
27
|
+
"Agent", secondary="groups_agents", lazy="selectin", passive_deletes=True, back_populates="groups"
|
|
28
|
+
)
|
|
29
|
+
manager_agent: Mapped["Agent"] = relationship("Agent", lazy="joined", back_populates="multi_agent_group")
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def agent_ids(self) -> List[str]:
|
|
33
|
+
return [agent.id for agent in self.agents]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from sqlalchemy import ForeignKey, String
|
|
2
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
3
|
+
|
|
4
|
+
from letta.orm.base import Base
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GroupsAgents(Base):
|
|
8
|
+
"""Agents may have one or many groups associated with them."""
|
|
9
|
+
|
|
10
|
+
__tablename__ = "groups_agents"
|
|
11
|
+
|
|
12
|
+
group_id: Mapped[str] = mapped_column(String, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True)
|
|
13
|
+
agent_id: Mapped[str] = mapped_column(String, ForeignKey("agents.id", ondelete="CASCADE"), primary_key=True)
|
letta/orm/message.py
CHANGED
|
@@ -4,11 +4,12 @@ from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMe
|
|
|
4
4
|
from sqlalchemy import ForeignKey, Index
|
|
5
5
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
6
6
|
|
|
7
|
-
from letta.orm.custom_columns import ToolCallColumn, ToolReturnColumn
|
|
7
|
+
from letta.orm.custom_columns import MessageContentColumn, ToolCallColumn, ToolReturnColumn
|
|
8
8
|
from letta.orm.mixins import AgentMixin, OrganizationMixin
|
|
9
9
|
from letta.orm.sqlalchemy_base import SqlalchemyBase
|
|
10
|
+
from letta.schemas.letta_message_content import MessageContent
|
|
11
|
+
from letta.schemas.letta_message_content import TextContent as PydanticTextContent
|
|
10
12
|
from letta.schemas.message import Message as PydanticMessage
|
|
11
|
-
from letta.schemas.message import TextContent as PydanticTextContent
|
|
12
13
|
from letta.schemas.message import ToolReturn
|
|
13
14
|
|
|
14
15
|
|
|
@@ -25,6 +26,7 @@ class Message(SqlalchemyBase, OrganizationMixin, AgentMixin):
|
|
|
25
26
|
id: Mapped[str] = mapped_column(primary_key=True, doc="Unique message identifier")
|
|
26
27
|
role: Mapped[str] = mapped_column(doc="Message role (user/assistant/system/tool)")
|
|
27
28
|
text: Mapped[Optional[str]] = mapped_column(nullable=True, doc="Message content")
|
|
29
|
+
content: Mapped[List[MessageContent]] = mapped_column(MessageContentColumn, nullable=True, doc="Message content parts")
|
|
28
30
|
model: Mapped[Optional[str]] = mapped_column(nullable=True, doc="LLM model used")
|
|
29
31
|
name: Mapped[Optional[str]] = mapped_column(nullable=True, doc="Name for multi-agent scenarios")
|
|
30
32
|
tool_calls: Mapped[List[OpenAIToolCall]] = mapped_column(ToolCallColumn, doc="Tool call information")
|
|
@@ -36,6 +38,7 @@ class Message(SqlalchemyBase, OrganizationMixin, AgentMixin):
|
|
|
36
38
|
tool_returns: Mapped[List[ToolReturn]] = mapped_column(
|
|
37
39
|
ToolReturnColumn, nullable=True, doc="Tool execution return information for prior tool calls"
|
|
38
40
|
)
|
|
41
|
+
group_id: Mapped[Optional[str]] = mapped_column(nullable=True, doc="The multi-agent group that the message was sent in")
|
|
39
42
|
|
|
40
43
|
# Relationships
|
|
41
44
|
agent: Mapped["Agent"] = relationship("Agent", back_populates="messages", lazy="selectin")
|
|
@@ -53,8 +56,8 @@ class Message(SqlalchemyBase, OrganizationMixin, AgentMixin):
|
|
|
53
56
|
return self.job_message.job if self.job_message else None
|
|
54
57
|
|
|
55
58
|
def to_pydantic(self) -> PydanticMessage:
|
|
56
|
-
"""
|
|
59
|
+
"""Custom pydantic conversion to handle data using legacy text field"""
|
|
57
60
|
model = self.__pydantic_model__.model_validate(self)
|
|
58
|
-
if self.text:
|
|
61
|
+
if self.text and not model.content:
|
|
59
62
|
model.content = [PydanticTextContent(text=self.text)]
|
|
60
63
|
return model
|
letta/orm/organization.py
CHANGED
|
@@ -49,6 +49,7 @@ class Organization(SqlalchemyBase):
|
|
|
49
49
|
agent_passages: Mapped[List["AgentPassage"]] = relationship("AgentPassage", back_populates="organization", cascade="all, delete-orphan")
|
|
50
50
|
providers: Mapped[List["Provider"]] = relationship("Provider", back_populates="organization", cascade="all, delete-orphan")
|
|
51
51
|
identities: Mapped[List["Identity"]] = relationship("Identity", back_populates="organization", cascade="all, delete-orphan")
|
|
52
|
+
groups: Mapped[List["Group"]] = relationship("Group", back_populates="organization", cascade="all, delete-orphan")
|
|
52
53
|
|
|
53
54
|
@property
|
|
54
55
|
def passages(self) -> List[Union["SourcePassage", "AgentPassage"]]:
|
letta/orm/sqlalchemy_base.py
CHANGED
|
@@ -139,11 +139,11 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
|
|
|
139
139
|
else:
|
|
140
140
|
# Match ANY tag - use join and filter
|
|
141
141
|
query = (
|
|
142
|
-
query.join(cls.tags).filter(cls.tags.property.mapper.class_.tag.in_(tags)).
|
|
142
|
+
query.join(cls.tags).filter(cls.tags.property.mapper.class_.tag.in_(tags)).distinct(cls.id).order_by(cls.id)
|
|
143
143
|
) # Deduplicate results
|
|
144
144
|
|
|
145
|
-
#
|
|
146
|
-
query = query.
|
|
145
|
+
# select distinct primary key
|
|
146
|
+
query = query.distinct(cls.id).order_by(cls.id)
|
|
147
147
|
|
|
148
148
|
if identifier_keys and hasattr(cls, "identities"):
|
|
149
149
|
query = query.join(cls.identities).filter(cls.identities.property.mapper.class_.identifier_key.in_(identifier_keys))
|