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.

Files changed (67) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +14 -4
  3. letta/agents/ephemeral_agent.py +2 -1
  4. letta/agents/low_latency_agent.py +8 -0
  5. letta/dynamic_multi_agent.py +274 -0
  6. letta/functions/function_sets/base.py +1 -0
  7. letta/functions/function_sets/extras.py +2 -1
  8. letta/functions/function_sets/multi_agent.py +17 -0
  9. letta/functions/helpers.py +41 -0
  10. letta/functions/mcp_client/__init__.py +0 -0
  11. letta/functions/mcp_client/base_client.py +61 -0
  12. letta/functions/mcp_client/sse_client.py +21 -0
  13. letta/functions/mcp_client/stdio_client.py +103 -0
  14. letta/functions/mcp_client/types.py +48 -0
  15. letta/functions/schema_generator.py +1 -1
  16. letta/helpers/converters.py +67 -0
  17. letta/llm_api/openai.py +1 -1
  18. letta/memory.py +2 -1
  19. letta/orm/__init__.py +2 -0
  20. letta/orm/agent.py +69 -20
  21. letta/orm/custom_columns.py +15 -0
  22. letta/orm/group.py +33 -0
  23. letta/orm/groups_agents.py +13 -0
  24. letta/orm/message.py +7 -4
  25. letta/orm/organization.py +1 -0
  26. letta/orm/sqlalchemy_base.py +3 -3
  27. letta/round_robin_multi_agent.py +152 -0
  28. letta/schemas/agent.py +3 -0
  29. letta/schemas/enums.py +0 -4
  30. letta/schemas/group.py +65 -0
  31. letta/schemas/letta_message.py +167 -106
  32. letta/schemas/letta_message_content.py +192 -0
  33. letta/schemas/message.py +28 -36
  34. letta/schemas/tool.py +1 -1
  35. letta/serialize_schemas/__init__.py +1 -1
  36. letta/serialize_schemas/marshmallow_agent.py +108 -0
  37. letta/serialize_schemas/{agent_environment_variable.py → marshmallow_agent_environment_variable.py} +1 -1
  38. letta/serialize_schemas/marshmallow_base.py +52 -0
  39. letta/serialize_schemas/{block.py → marshmallow_block.py} +1 -1
  40. letta/serialize_schemas/{custom_fields.py → marshmallow_custom_fields.py} +12 -0
  41. letta/serialize_schemas/marshmallow_message.py +42 -0
  42. letta/serialize_schemas/{tag.py → marshmallow_tag.py} +12 -2
  43. letta/serialize_schemas/{tool.py → marshmallow_tool.py} +1 -1
  44. letta/serialize_schemas/pydantic_agent_schema.py +111 -0
  45. letta/server/rest_api/app.py +15 -0
  46. letta/server/rest_api/routers/v1/__init__.py +2 -0
  47. letta/server/rest_api/routers/v1/agents.py +46 -40
  48. letta/server/rest_api/routers/v1/groups.py +233 -0
  49. letta/server/rest_api/routers/v1/tools.py +31 -3
  50. letta/server/rest_api/utils.py +1 -1
  51. letta/server/server.py +272 -22
  52. letta/services/agent_manager.py +65 -28
  53. letta/services/group_manager.py +147 -0
  54. letta/services/helpers/agent_manager_helper.py +151 -1
  55. letta/services/message_manager.py +11 -3
  56. letta/services/passage_manager.py +15 -0
  57. letta/settings.py +5 -0
  58. letta/supervisor_multi_agent.py +103 -0
  59. {letta_nightly-0.6.39.dev20250314104053.dist-info → letta_nightly-0.6.40.dev20250314222759.dist-info}/METADATA +1 -2
  60. {letta_nightly-0.6.39.dev20250314104053.dist-info → letta_nightly-0.6.40.dev20250314222759.dist-info}/RECORD +63 -49
  61. letta/helpers/mcp_helpers.py +0 -108
  62. letta/serialize_schemas/agent.py +0 -80
  63. letta/serialize_schemas/base.py +0 -64
  64. letta/serialize_schemas/message.py +0 -29
  65. {letta_nightly-0.6.39.dev20250314104053.dist-info → letta_nightly-0.6.40.dev20250314222759.dist-info}/LICENSE +0 -0
  66. {letta_nightly-0.6.39.dev20250314104053.dist-info → letta_nightly-0.6.40.dev20250314222759.dist-info}/WHEEL +0 -0
  67. {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.helpers.mcp_helpers import MCPTool
9
+ from letta.functions.mcp_client.types import MCPTool
10
10
 
11
11
 
12
12
  def is_optional(annotation):
@@ -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
@@ -221,7 +221,7 @@ def openai_chat_completions_process_stream(
221
221
  # TODO(sarah): add message ID generation function
222
222
  dummy_message = _Message(
223
223
  role=_MessageRole.assistant,
224
- text="",
224
+ content=[],
225
225
  agent_id="",
226
226
  model="",
227
227
  name=None,
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, TextContent
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
- def to_pydantic(self) -> PydanticAgentState:
133
- """converts to the basic pydantic model counterpart"""
134
- # add default rule for having send_message be a terminal tool
135
- tool_rules = self.tool_rules
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
- "organization_id": self.organization_id,
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
- "agent_type": self.agent_type,
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
- "identity_ids": [identity.id for identity in self.identities],
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)
@@ -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
- """custom pydantic conversion for message content mapping"""
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"]]:
@@ -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)).group_by(cls.id)
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
- # Group by primary key and all necessary columns to avoid JSON comparison
146
- query = query.group_by(cls.id)
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))