letta-nightly 0.8.15.dev20250720104313__py3-none-any.whl → 0.8.16.dev20250721104533__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- letta/__init__.py +1 -1
- letta/agent.py +27 -11
- letta/agents/helpers.py +1 -1
- letta/agents/letta_agent.py +518 -322
- letta/agents/letta_agent_batch.py +1 -2
- letta/agents/voice_agent.py +15 -17
- letta/client/client.py +3 -3
- letta/constants.py +5 -0
- letta/embeddings.py +0 -2
- letta/errors.py +8 -0
- letta/functions/function_sets/base.py +3 -3
- letta/functions/helpers.py +2 -3
- letta/groups/sleeptime_multi_agent.py +0 -1
- letta/helpers/composio_helpers.py +2 -2
- letta/helpers/converters.py +1 -1
- letta/helpers/pinecone_utils.py +8 -0
- letta/helpers/tool_rule_solver.py +13 -18
- letta/llm_api/aws_bedrock.py +16 -2
- letta/llm_api/cohere.py +1 -1
- letta/llm_api/openai_client.py +1 -1
- letta/local_llm/grammars/gbnf_grammar_generator.py +1 -1
- letta/local_llm/llm_chat_completion_wrappers/zephyr.py +14 -14
- letta/local_llm/utils.py +1 -2
- letta/orm/agent.py +3 -3
- letta/orm/block.py +4 -4
- letta/orm/files_agents.py +0 -1
- letta/orm/identity.py +2 -0
- letta/orm/mcp_server.py +0 -2
- letta/orm/message.py +140 -14
- letta/orm/organization.py +5 -5
- letta/orm/passage.py +4 -4
- letta/orm/source.py +1 -1
- letta/orm/sqlalchemy_base.py +61 -39
- letta/orm/step.py +2 -0
- letta/otel/db_pool_monitoring.py +308 -0
- letta/otel/metric_registry.py +94 -1
- letta/otel/sqlalchemy_instrumentation.py +548 -0
- letta/otel/sqlalchemy_instrumentation_integration.py +124 -0
- letta/otel/tracing.py +37 -1
- letta/schemas/agent.py +0 -3
- letta/schemas/agent_file.py +283 -0
- letta/schemas/block.py +0 -3
- letta/schemas/file.py +28 -26
- letta/schemas/letta_message.py +15 -4
- letta/schemas/memory.py +1 -1
- letta/schemas/message.py +31 -26
- letta/schemas/openai/chat_completion_response.py +0 -1
- letta/schemas/providers.py +20 -0
- letta/schemas/source.py +11 -13
- letta/schemas/step.py +12 -0
- letta/schemas/tool.py +0 -4
- letta/serialize_schemas/marshmallow_agent.py +14 -1
- letta/serialize_schemas/marshmallow_block.py +23 -1
- letta/serialize_schemas/marshmallow_message.py +1 -3
- letta/serialize_schemas/marshmallow_tool.py +23 -1
- letta/server/db.py +110 -6
- letta/server/rest_api/app.py +85 -73
- letta/server/rest_api/routers/v1/agents.py +68 -53
- letta/server/rest_api/routers/v1/blocks.py +2 -2
- letta/server/rest_api/routers/v1/jobs.py +3 -0
- letta/server/rest_api/routers/v1/organizations.py +2 -2
- letta/server/rest_api/routers/v1/sources.py +18 -2
- letta/server/rest_api/routers/v1/tools.py +11 -12
- letta/server/rest_api/routers/v1/users.py +1 -1
- letta/server/rest_api/streaming_response.py +13 -5
- letta/server/rest_api/utils.py +8 -25
- letta/server/server.py +11 -4
- letta/server/ws_api/server.py +2 -2
- letta/services/agent_file_manager.py +616 -0
- letta/services/agent_manager.py +133 -46
- letta/services/block_manager.py +38 -17
- letta/services/file_manager.py +106 -21
- letta/services/file_processor/file_processor.py +93 -0
- letta/services/files_agents_manager.py +28 -0
- letta/services/group_manager.py +4 -5
- letta/services/helpers/agent_manager_helper.py +57 -9
- letta/services/identity_manager.py +22 -0
- letta/services/job_manager.py +210 -91
- letta/services/llm_batch_manager.py +9 -6
- letta/services/mcp/stdio_client.py +1 -2
- letta/services/mcp_manager.py +0 -1
- letta/services/message_manager.py +49 -26
- letta/services/passage_manager.py +0 -1
- letta/services/provider_manager.py +1 -1
- letta/services/source_manager.py +114 -5
- letta/services/step_manager.py +36 -4
- letta/services/telemetry_manager.py +9 -2
- letta/services/tool_executor/builtin_tool_executor.py +5 -1
- letta/services/tool_executor/core_tool_executor.py +3 -3
- letta/services/tool_manager.py +95 -20
- letta/services/user_manager.py +4 -12
- letta/settings.py +23 -6
- letta/system.py +1 -1
- letta/utils.py +26 -2
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/METADATA +3 -2
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/RECORD +99 -94
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/LICENSE +0 -0
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/WHEEL +0 -0
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/entry_points.txt +0 -0
letta/services/source_manager.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
import asyncio
|
2
2
|
from typing import List, Optional
|
3
3
|
|
4
|
-
from sqlalchemy import select
|
4
|
+
from sqlalchemy import and_, exists, select
|
5
5
|
|
6
6
|
from letta.orm import Agent as AgentModel
|
7
7
|
from letta.orm.errors import NoResultFound
|
@@ -19,6 +19,30 @@ from letta.utils import enforce_types, printd
|
|
19
19
|
class SourceManager:
|
20
20
|
"""Manager class to handle business logic related to Sources."""
|
21
21
|
|
22
|
+
@trace_method
|
23
|
+
async def _validate_source_exists_async(self, session, source_id: str, actor: PydanticUser) -> None:
|
24
|
+
"""
|
25
|
+
Validate that a source exists and user has access to it using raw SQL for efficiency.
|
26
|
+
|
27
|
+
Args:
|
28
|
+
session: Database session
|
29
|
+
source_id: ID of the source to validate
|
30
|
+
actor: User performing the action
|
31
|
+
|
32
|
+
Raises:
|
33
|
+
NoResultFound: If source doesn't exist or user doesn't have access
|
34
|
+
"""
|
35
|
+
source_exists_query = select(
|
36
|
+
exists().where(
|
37
|
+
and_(SourceModel.id == source_id, SourceModel.organization_id == actor.organization_id, SourceModel.is_deleted == False)
|
38
|
+
)
|
39
|
+
)
|
40
|
+
|
41
|
+
result = await session.execute(source_exists_query)
|
42
|
+
|
43
|
+
if not result.scalar():
|
44
|
+
raise NoResultFound(f"Source with ID {source_id} not found")
|
45
|
+
|
22
46
|
@enforce_types
|
23
47
|
@trace_method
|
24
48
|
async def create_source(self, source: PydanticSource, actor: PydanticUser) -> PydanticSource:
|
@@ -93,20 +117,20 @@ class SourceManager:
|
|
93
117
|
|
94
118
|
@enforce_types
|
95
119
|
@trace_method
|
96
|
-
async def list_attached_agents(self, source_id: str, actor:
|
120
|
+
async def list_attached_agents(self, source_id: str, actor: PydanticUser) -> List[PydanticAgentState]:
|
97
121
|
"""
|
98
122
|
Lists all agents that have the specified source attached.
|
99
123
|
|
100
124
|
Args:
|
101
125
|
source_id: ID of the source to find attached agents for
|
102
|
-
actor: User performing the action
|
126
|
+
actor: User performing the action
|
103
127
|
|
104
128
|
Returns:
|
105
129
|
List[PydanticAgentState]: List of agents that have this source attached
|
106
130
|
"""
|
107
131
|
async with db_registry.async_session() as session:
|
108
132
|
# Verify source exists and user has permission to access it
|
109
|
-
|
133
|
+
await self._validate_source_exists_async(session, source_id, actor)
|
110
134
|
|
111
135
|
# Use junction table query instead of relationship to avoid performance issues
|
112
136
|
query = (
|
@@ -114,7 +138,7 @@ class SourceManager:
|
|
114
138
|
.join(SourcesAgents, AgentModel.id == SourcesAgents.agent_id)
|
115
139
|
.where(
|
116
140
|
SourcesAgents.source_id == source_id,
|
117
|
-
AgentModel.organization_id == actor.organization_id
|
141
|
+
AgentModel.organization_id == actor.organization_id,
|
118
142
|
AgentModel.is_deleted == False,
|
119
143
|
)
|
120
144
|
.order_by(AgentModel.created_at.desc(), AgentModel.id)
|
@@ -125,6 +149,31 @@ class SourceManager:
|
|
125
149
|
|
126
150
|
return await asyncio.gather(*[agent.to_pydantic_async() for agent in agents_orm])
|
127
151
|
|
152
|
+
@enforce_types
|
153
|
+
@trace_method
|
154
|
+
async def get_agents_for_source_id(self, source_id: str, actor: PydanticUser) -> List[str]:
|
155
|
+
"""
|
156
|
+
Get all agent IDs associated with a given source ID.
|
157
|
+
|
158
|
+
Args:
|
159
|
+
source_id: ID of the source to find agents for
|
160
|
+
actor: User performing the action
|
161
|
+
|
162
|
+
Returns:
|
163
|
+
List[str]: List of agent IDs that have this source attached
|
164
|
+
"""
|
165
|
+
async with db_registry.async_session() as session:
|
166
|
+
# Verify source exists and user has permission to access it
|
167
|
+
await self._validate_source_exists_async(session, source_id, actor)
|
168
|
+
|
169
|
+
# Query the junction table directly for performance
|
170
|
+
query = select(SourcesAgents.agent_id).where(SourcesAgents.source_id == source_id)
|
171
|
+
|
172
|
+
result = await session.execute(query)
|
173
|
+
agent_ids = result.scalars().all()
|
174
|
+
|
175
|
+
return list(agent_ids)
|
176
|
+
|
128
177
|
# TODO: We make actor optional for now, but should most likely be enforced due to security reasons
|
129
178
|
@enforce_types
|
130
179
|
@trace_method
|
@@ -152,3 +201,63 @@ class SourceManager:
|
|
152
201
|
return None
|
153
202
|
else:
|
154
203
|
return sources[0].to_pydantic()
|
204
|
+
|
205
|
+
@enforce_types
|
206
|
+
@trace_method
|
207
|
+
async def get_sources_by_ids_async(self, source_ids: List[str], actor: PydanticUser) -> List[PydanticSource]:
|
208
|
+
"""
|
209
|
+
Get multiple sources by their IDs in a single query.
|
210
|
+
|
211
|
+
Args:
|
212
|
+
source_ids: List of source IDs to retrieve
|
213
|
+
actor: User performing the action
|
214
|
+
|
215
|
+
Returns:
|
216
|
+
List[PydanticSource]: List of sources (may be fewer than requested if some don't exist)
|
217
|
+
"""
|
218
|
+
if not source_ids:
|
219
|
+
return []
|
220
|
+
|
221
|
+
async with db_registry.async_session() as session:
|
222
|
+
query = select(SourceModel).where(
|
223
|
+
SourceModel.id.in_(source_ids), SourceModel.organization_id == actor.organization_id, SourceModel.is_deleted == False
|
224
|
+
)
|
225
|
+
|
226
|
+
result = await session.execute(query)
|
227
|
+
sources_orm = result.scalars().all()
|
228
|
+
|
229
|
+
return [source.to_pydantic() for source in sources_orm]
|
230
|
+
|
231
|
+
@enforce_types
|
232
|
+
@trace_method
|
233
|
+
async def get_sources_for_agents_async(self, agent_ids: List[str], actor: PydanticUser) -> List[PydanticSource]:
|
234
|
+
"""
|
235
|
+
Get all sources associated with the given agents via sources-agents relationships.
|
236
|
+
|
237
|
+
Args:
|
238
|
+
agent_ids: List of agent IDs to find sources for
|
239
|
+
actor: User performing the action
|
240
|
+
|
241
|
+
Returns:
|
242
|
+
List[PydanticSource]: List of unique sources associated with these agents
|
243
|
+
"""
|
244
|
+
if not agent_ids:
|
245
|
+
return []
|
246
|
+
|
247
|
+
async with db_registry.async_session() as session:
|
248
|
+
# Join through sources-agents junction table
|
249
|
+
query = (
|
250
|
+
select(SourceModel)
|
251
|
+
.join(SourcesAgents, SourceModel.id == SourcesAgents.source_id)
|
252
|
+
.where(
|
253
|
+
SourcesAgents.agent_id.in_(agent_ids),
|
254
|
+
SourceModel.organization_id == actor.organization_id,
|
255
|
+
SourceModel.is_deleted == False,
|
256
|
+
)
|
257
|
+
.distinct() # Ensure we don't get duplicate sources
|
258
|
+
)
|
259
|
+
|
260
|
+
result = await session.execute(query)
|
261
|
+
sources_orm = result.scalars().all()
|
262
|
+
|
263
|
+
return [source.to_pydantic() for source in sources_orm]
|
letta/services/step_manager.py
CHANGED
@@ -12,6 +12,7 @@ from letta.orm.job import Job as JobModel
|
|
12
12
|
from letta.orm.sqlalchemy_base import AccessType
|
13
13
|
from letta.orm.step import Step as StepModel
|
14
14
|
from letta.otel.tracing import get_trace_id, trace_method
|
15
|
+
from letta.schemas.letta_stop_reason import LettaStopReason, StopReasonType
|
15
16
|
from letta.schemas.openai.chat_completion_response import UsageStatistics
|
16
17
|
from letta.schemas.step import Step as PydanticStep
|
17
18
|
from letta.schemas.user import User as PydanticUser
|
@@ -131,6 +132,7 @@ class StepManager:
|
|
131
132
|
job_id: Optional[str] = None,
|
132
133
|
step_id: Optional[str] = None,
|
133
134
|
project_id: Optional[str] = None,
|
135
|
+
stop_reason: Optional[LettaStopReason] = None,
|
134
136
|
) -> PydanticStep:
|
135
137
|
step_data = {
|
136
138
|
"origin": None,
|
@@ -153,12 +155,14 @@ class StepManager:
|
|
153
155
|
}
|
154
156
|
if step_id:
|
155
157
|
step_data["id"] = step_id
|
158
|
+
if stop_reason:
|
159
|
+
step_data["stop_reason"] = stop_reason.stop_reason
|
156
160
|
async with db_registry.async_session() as session:
|
157
|
-
if job_id:
|
158
|
-
await self._verify_job_access_async(session, job_id, actor, access=["write"])
|
159
161
|
new_step = StepModel(**step_data)
|
160
|
-
await new_step.create_async(session)
|
161
|
-
|
162
|
+
await new_step.create_async(session, no_commit=True, no_refresh=True)
|
163
|
+
pydantic_step = new_step.to_pydantic()
|
164
|
+
await session.commit()
|
165
|
+
return pydantic_step
|
162
166
|
|
163
167
|
@enforce_types
|
164
168
|
@trace_method
|
@@ -205,6 +209,33 @@ class StepManager:
|
|
205
209
|
await session.commit()
|
206
210
|
return step.to_pydantic()
|
207
211
|
|
212
|
+
@enforce_types
|
213
|
+
@trace_method
|
214
|
+
async def update_step_stop_reason(self, actor: PydanticUser, step_id: str, stop_reason: StopReasonType) -> PydanticStep:
|
215
|
+
"""Update the stop reason for a step.
|
216
|
+
|
217
|
+
Args:
|
218
|
+
actor: The user making the request
|
219
|
+
step_id: The ID of the step to update
|
220
|
+
stop_reason: The stop reason to set
|
221
|
+
|
222
|
+
Returns:
|
223
|
+
The updated step
|
224
|
+
|
225
|
+
Raises:
|
226
|
+
NoResultFound: If the step does not exist
|
227
|
+
"""
|
228
|
+
async with db_registry.async_session() as session:
|
229
|
+
step = await session.get(StepModel, step_id)
|
230
|
+
if not step:
|
231
|
+
raise NoResultFound(f"Step with id {step_id} does not exist")
|
232
|
+
if step.organization_id != actor.organization_id:
|
233
|
+
raise Exception("Unauthorized")
|
234
|
+
|
235
|
+
step.stop_reason = stop_reason
|
236
|
+
await session.commit()
|
237
|
+
return step
|
238
|
+
|
208
239
|
def _verify_job_access(
|
209
240
|
self,
|
210
241
|
session: Session,
|
@@ -307,5 +338,6 @@ class NoopStepManager(StepManager):
|
|
307
338
|
job_id: Optional[str] = None,
|
308
339
|
step_id: Optional[str] = None,
|
309
340
|
project_id: Optional[str] = None,
|
341
|
+
stop_reason: Optional[LettaStopReason] = None,
|
310
342
|
) -> PydanticStep:
|
311
343
|
return
|
@@ -1,6 +1,7 @@
|
|
1
1
|
from letta.helpers.json_helpers import json_dumps, json_loads
|
2
2
|
from letta.helpers.singleton import singleton
|
3
3
|
from letta.orm.provider_trace import ProviderTrace as ProviderTraceModel
|
4
|
+
from letta.otel.tracing import trace_method
|
4
5
|
from letta.schemas.provider_trace import ProviderTrace as PydanticProviderTrace
|
5
6
|
from letta.schemas.provider_trace import ProviderTraceCreate
|
6
7
|
from letta.schemas.step import Step as PydanticStep
|
@@ -10,7 +11,9 @@ from letta.utils import enforce_types
|
|
10
11
|
|
11
12
|
|
12
13
|
class TelemetryManager:
|
14
|
+
|
13
15
|
@enforce_types
|
16
|
+
@trace_method
|
14
17
|
async def get_provider_trace_by_step_id_async(
|
15
18
|
self,
|
16
19
|
step_id: str,
|
@@ -21,6 +24,7 @@ class TelemetryManager:
|
|
21
24
|
return provider_trace.to_pydantic()
|
22
25
|
|
23
26
|
@enforce_types
|
27
|
+
@trace_method
|
24
28
|
async def create_provider_trace_async(self, actor: PydanticUser, provider_trace_create: ProviderTraceCreate) -> PydanticProviderTrace:
|
25
29
|
async with db_registry.async_session() as session:
|
26
30
|
provider_trace = ProviderTraceModel(**provider_trace_create.model_dump())
|
@@ -31,10 +35,13 @@ class TelemetryManager:
|
|
31
35
|
if provider_trace_create.response_json:
|
32
36
|
response_json_str = json_dumps(provider_trace_create.response_json)
|
33
37
|
provider_trace.response_json = json_loads(response_json_str)
|
34
|
-
await provider_trace.create_async(session, actor=actor)
|
35
|
-
|
38
|
+
await provider_trace.create_async(session, actor=actor, no_commit=True, no_refresh=True)
|
39
|
+
pydantic_provider_trace = provider_trace.to_pydantic()
|
40
|
+
await session.commit()
|
41
|
+
return pydantic_provider_trace
|
36
42
|
|
37
43
|
@enforce_types
|
44
|
+
@trace_method
|
38
45
|
def create_provider_trace(self, actor: PydanticUser, provider_trace_create: ProviderTraceCreate) -> PydanticProviderTrace:
|
39
46
|
with db_registry.session() as session:
|
40
47
|
provider_trace = ProviderTraceModel(**provider_trace_create.model_dump())
|
@@ -1,10 +1,12 @@
|
|
1
1
|
import asyncio
|
2
2
|
import json
|
3
|
+
import os
|
3
4
|
import time
|
4
5
|
from typing import Any, Dict, List, Literal, Optional
|
5
6
|
|
6
7
|
from pydantic import BaseModel
|
7
8
|
|
9
|
+
from letta.constants import WEB_SEARCH_MODEL_ENV_VAR_DEFAULT_VALUE, WEB_SEARCH_MODEL_ENV_VAR_NAME
|
8
10
|
from letta.functions.prompts import FIRECRAWL_SEARCH_SYSTEM_PROMPT, get_firecrawl_search_user_prompt
|
9
11
|
from letta.functions.types import SearchTask
|
10
12
|
from letta.log import get_logger
|
@@ -322,8 +324,10 @@ class LettaBuiltinToolExecutor(ToolExecutor):
|
|
322
324
|
# Time the OpenAI request
|
323
325
|
start_time = time.time()
|
324
326
|
|
327
|
+
model = os.getenv(WEB_SEARCH_MODEL_ENV_VAR_NAME, WEB_SEARCH_MODEL_ENV_VAR_DEFAULT_VALUE)
|
328
|
+
logger.info(f"Using model {model} for web search result parsing")
|
325
329
|
response = await client.beta.chat.completions.parse(
|
326
|
-
model=
|
330
|
+
model=model,
|
327
331
|
messages=[{"role": "system", "content": FIRECRAWL_SEARCH_SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}],
|
328
332
|
response_format=DocumentAnalysis,
|
329
333
|
temperature=0.1,
|
@@ -96,7 +96,7 @@ class LettaCoreToolExecutor(ToolExecutor):
|
|
96
96
|
try:
|
97
97
|
page = int(page)
|
98
98
|
except:
|
99
|
-
raise ValueError(
|
99
|
+
raise ValueError("'page' argument must be an integer")
|
100
100
|
|
101
101
|
count = RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE
|
102
102
|
messages = await MessageManager().list_user_messages_for_agent_async(
|
@@ -110,7 +110,7 @@ class LettaCoreToolExecutor(ToolExecutor):
|
|
110
110
|
num_pages = math.ceil(total / count) - 1 # 0 index
|
111
111
|
|
112
112
|
if len(messages) == 0:
|
113
|
-
results_str =
|
113
|
+
results_str = "No results found."
|
114
114
|
else:
|
115
115
|
results_pref = f"Showing {len(messages)} of {total} results (page {page}/{num_pages}):"
|
116
116
|
results_formatted = [message.content[0].text for message in messages]
|
@@ -137,7 +137,7 @@ class LettaCoreToolExecutor(ToolExecutor):
|
|
137
137
|
try:
|
138
138
|
page = int(page)
|
139
139
|
except:
|
140
|
-
raise ValueError(
|
140
|
+
raise ValueError("'page' argument must be an integer")
|
141
141
|
|
142
142
|
count = RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE
|
143
143
|
|
letta/services/tool_manager.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
|
-
import asyncio
|
2
1
|
import importlib
|
2
|
+
import os
|
3
3
|
import warnings
|
4
4
|
from typing import List, Optional, Set, Union
|
5
5
|
|
@@ -14,6 +14,7 @@ from letta.constants import (
|
|
14
14
|
FILES_TOOLS,
|
15
15
|
LETTA_TOOL_MODULE_NAMES,
|
16
16
|
LETTA_TOOL_SET,
|
17
|
+
LOCAL_ONLY_MULTI_AGENT_TOOLS,
|
17
18
|
MCP_TOOL_TAG_NAME_PREFIX,
|
18
19
|
)
|
19
20
|
from letta.functions.functions import derive_openai_json_schema, load_function_set
|
@@ -30,6 +31,7 @@ from letta.schemas.user import User as PydanticUser
|
|
30
31
|
from letta.server.db import db_registry
|
31
32
|
from letta.services.helpers.agent_manager_helper import calculate_multi_agent_tools
|
32
33
|
from letta.services.mcp.types import SSEServerConfig, StdioServerConfig
|
34
|
+
from letta.settings import settings
|
33
35
|
from letta.utils import enforce_types, printd
|
34
36
|
|
35
37
|
logger = get_logger(__name__)
|
@@ -74,6 +76,7 @@ class ToolManager:
|
|
74
76
|
if tool_id:
|
75
77
|
# Put to dict and remove fields that should not be reset
|
76
78
|
update_data = pydantic_tool.model_dump(exclude_unset=True, exclude_none=True)
|
79
|
+
update_data["organization_id"] = actor.organization_id
|
77
80
|
|
78
81
|
# If there's anything to update
|
79
82
|
if update_data:
|
@@ -146,12 +149,12 @@ class ToolManager:
|
|
146
149
|
def create_tool(self, pydantic_tool: PydanticTool, actor: PydanticUser) -> PydanticTool:
|
147
150
|
"""Create a new tool based on the ToolCreate schema."""
|
148
151
|
with db_registry.session() as session:
|
149
|
-
# Set the organization id at the ORM layer
|
150
|
-
pydantic_tool.organization_id = actor.organization_id
|
151
152
|
# Auto-generate description if not provided
|
152
153
|
if pydantic_tool.description is None:
|
153
154
|
pydantic_tool.description = pydantic_tool.json_schema.get("description", None)
|
154
155
|
tool_data = pydantic_tool.model_dump(to_orm=True)
|
156
|
+
# Set the organization id at the ORM layer
|
157
|
+
tool_data["organization_id"] = actor.organization_id
|
155
158
|
|
156
159
|
tool = ToolModel(**tool_data)
|
157
160
|
tool.create(session, actor=actor) # Re-raise other database-related errors
|
@@ -162,12 +165,12 @@ class ToolManager:
|
|
162
165
|
async def create_tool_async(self, pydantic_tool: PydanticTool, actor: PydanticUser) -> PydanticTool:
|
163
166
|
"""Create a new tool based on the ToolCreate schema."""
|
164
167
|
async with db_registry.async_session() as session:
|
165
|
-
# Set the organization id at the ORM layer
|
166
|
-
pydantic_tool.organization_id = actor.organization_id
|
167
168
|
# Auto-generate description if not provided
|
168
169
|
if pydantic_tool.description is None:
|
169
170
|
pydantic_tool.description = pydantic_tool.json_schema.get("description", None)
|
170
171
|
tool_data = pydantic_tool.model_dump(to_orm=True)
|
172
|
+
# Set the organization id at the ORM layer
|
173
|
+
tool_data["organization_id"] = actor.organization_id
|
171
174
|
|
172
175
|
tool = ToolModel(**tool_data)
|
173
176
|
await tool.create_async(session, actor=actor) # Re-raise other database-related errors
|
@@ -250,7 +253,10 @@ class ToolManager:
|
|
250
253
|
# TODO: This requires a deeper rethink about how we keep all our internal tools up-to-date
|
251
254
|
if not after and upsert_base_tools:
|
252
255
|
existing_tool_names = {tool.name for tool in tools}
|
253
|
-
|
256
|
+
base_tool_names = (
|
257
|
+
LETTA_TOOL_SET - set(LOCAL_ONLY_MULTI_AGENT_TOOLS) if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION" else LETTA_TOOL_SET
|
258
|
+
)
|
259
|
+
missing_base_tools = base_tool_names - existing_tool_names
|
254
260
|
|
255
261
|
# If any base tools are missing, upsert all base tools
|
256
262
|
if missing_base_tools:
|
@@ -462,7 +468,10 @@ class ToolManager:
|
|
462
468
|
actor: PydanticUser,
|
463
469
|
allowed_types: Optional[Set[ToolType]] = None,
|
464
470
|
) -> List[PydanticTool]:
|
465
|
-
"""Add default tools defined in the various function_sets modules, optionally filtered by ToolType.
|
471
|
+
"""Add default tools defined in the various function_sets modules, optionally filtered by ToolType.
|
472
|
+
|
473
|
+
Optimized bulk implementation using single database session and batch operations.
|
474
|
+
"""
|
466
475
|
|
467
476
|
functions_to_schema = {}
|
468
477
|
for module_name in LETTA_TOOL_MODULE_NAMES:
|
@@ -474,7 +483,8 @@ class ToolManager:
|
|
474
483
|
except Exception as e:
|
475
484
|
raise e
|
476
485
|
|
477
|
-
|
486
|
+
# prepare tool data for bulk operations
|
487
|
+
tool_data_list = []
|
478
488
|
for name, schema in functions_to_schema.items():
|
479
489
|
if name not in LETTA_TOOL_SET:
|
480
490
|
continue
|
@@ -500,17 +510,82 @@ class ToolManager:
|
|
500
510
|
if allowed_types is not None and tool_type not in allowed_types:
|
501
511
|
continue
|
502
512
|
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
return_char_limit=BASE_FUNCTION_RETURN_CHAR_LIMIT,
|
511
|
-
),
|
512
|
-
actor=actor,
|
513
|
-
)
|
513
|
+
# create pydantic tool for validation and conversion
|
514
|
+
pydantic_tool = PydanticTool(
|
515
|
+
name=name,
|
516
|
+
tags=[tool_type.value],
|
517
|
+
source_type="python",
|
518
|
+
tool_type=tool_type,
|
519
|
+
return_char_limit=BASE_FUNCTION_RETURN_CHAR_LIMIT,
|
514
520
|
)
|
515
521
|
|
516
|
-
|
522
|
+
# auto-generate description if not provided
|
523
|
+
if pydantic_tool.description is None:
|
524
|
+
pydantic_tool.description = pydantic_tool.json_schema.get("description", None)
|
525
|
+
|
526
|
+
tool_data_list.append(pydantic_tool)
|
527
|
+
|
528
|
+
if not tool_data_list:
|
529
|
+
return []
|
530
|
+
|
531
|
+
if settings.letta_pg_uri_no_default:
|
532
|
+
async with db_registry.async_session() as session:
|
533
|
+
return await self._bulk_upsert_postgresql(session, tool_data_list, actor)
|
534
|
+
else:
|
535
|
+
return await self._upsert_tools_individually(tool_data_list, actor)
|
536
|
+
|
537
|
+
@trace_method
|
538
|
+
async def _bulk_upsert_postgresql(self, session, tool_data_list: List[PydanticTool], actor: PydanticUser) -> List[PydanticTool]:
|
539
|
+
"""hyper-optimized postgresql bulk upsert using on_conflict_do_update."""
|
540
|
+
from sqlalchemy import func, select
|
541
|
+
from sqlalchemy.dialects.postgresql import insert
|
542
|
+
|
543
|
+
# prepare data for bulk insert
|
544
|
+
table = ToolModel.__table__
|
545
|
+
valid_columns = {col.name for col in table.columns}
|
546
|
+
|
547
|
+
insert_data = []
|
548
|
+
for tool in tool_data_list:
|
549
|
+
tool_dict = tool.model_dump(to_orm=True)
|
550
|
+
# set created/updated by fields
|
551
|
+
if actor:
|
552
|
+
tool_dict["_created_by_id"] = actor.id
|
553
|
+
tool_dict["_last_updated_by_id"] = actor.id
|
554
|
+
tool_dict["organization_id"] = actor.organization_id
|
555
|
+
|
556
|
+
# filter to only include columns that exist in the table
|
557
|
+
filtered_dict = {k: v for k, v in tool_dict.items() if k in valid_columns}
|
558
|
+
insert_data.append(filtered_dict)
|
559
|
+
|
560
|
+
# use postgresql's native bulk upsert
|
561
|
+
stmt = insert(table).values(insert_data)
|
562
|
+
|
563
|
+
# on conflict, update all columns except id, created_at, and _created_by_id
|
564
|
+
excluded = stmt.excluded
|
565
|
+
update_dict = {}
|
566
|
+
for col in table.columns:
|
567
|
+
if col.name not in ("id", "created_at", "_created_by_id"):
|
568
|
+
if col.name == "updated_at":
|
569
|
+
update_dict[col.name] = func.now()
|
570
|
+
else:
|
571
|
+
update_dict[col.name] = excluded[col.name]
|
572
|
+
|
573
|
+
upsert_stmt = stmt.on_conflict_do_update(index_elements=["name", "organization_id"], set_=update_dict)
|
574
|
+
|
575
|
+
await session.execute(upsert_stmt)
|
576
|
+
await session.commit()
|
577
|
+
|
578
|
+
# fetch results
|
579
|
+
tool_names = [tool.name for tool in tool_data_list]
|
580
|
+
result_query = select(ToolModel).where(ToolModel.name.in_(tool_names), ToolModel.organization_id == actor.organization_id)
|
581
|
+
result = await session.execute(result_query)
|
582
|
+
return [tool.to_pydantic() for tool in result.scalars()]
|
583
|
+
|
584
|
+
@trace_method
|
585
|
+
async def _upsert_tools_individually(self, tool_data_list: List[PydanticTool], actor: PydanticUser) -> List[PydanticTool]:
|
586
|
+
"""fallback to individual upserts for sqlite (original approach)."""
|
587
|
+
tools = []
|
588
|
+
for tool in tool_data_list:
|
589
|
+
upserted_tool = await self.create_or_update_tool_async(tool, actor)
|
590
|
+
tools.append(upserted_tool)
|
591
|
+
return tools
|
letta/services/user_manager.py
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
from typing import List, Optional
|
2
2
|
|
3
|
-
from sqlalchemy import select
|
3
|
+
from sqlalchemy import select
|
4
4
|
|
5
5
|
from letta.constants import DEFAULT_ORG_ID
|
6
6
|
from letta.data_sources.redis_client import get_redis_client
|
@@ -8,7 +8,6 @@ from letta.helpers.decorators import async_redis_cache
|
|
8
8
|
from letta.log import get_logger
|
9
9
|
from letta.orm.errors import NoResultFound
|
10
10
|
from letta.orm.organization import Organization as OrganizationModel
|
11
|
-
from letta.orm.sqlalchemy_base import is_postgresql_session
|
12
11
|
from letta.orm.user import User as UserModel
|
13
12
|
from letta.otel.tracing import trace_method
|
14
13
|
from letta.schemas.user import User as PydanticUser
|
@@ -157,16 +156,9 @@ class UserManager:
|
|
157
156
|
async def get_actor_by_id_async(self, actor_id: str) -> PydanticUser:
|
158
157
|
"""Fetch a user by ID asynchronously."""
|
159
158
|
async with db_registry.async_session() as session:
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
try:
|
164
|
-
stmt = select(UserModel).where(UserModel.id == actor_id)
|
165
|
-
result = await session.execute(stmt)
|
166
|
-
user = result.scalar_one_or_none()
|
167
|
-
finally:
|
168
|
-
if is_postgresql_session(session):
|
169
|
-
await session.execute(text("SET LOCAL enable_seqscan = ON"))
|
159
|
+
stmt = select(UserModel).where(UserModel.id == actor_id)
|
160
|
+
result = await session.execute(stmt)
|
161
|
+
user = result.scalar_one_or_none()
|
170
162
|
|
171
163
|
if not user:
|
172
164
|
raise NoResultFound(f"User not found with id={actor_id}")
|
letta/settings.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
import os
|
2
|
+
from enum import Enum
|
2
3
|
from pathlib import Path
|
3
4
|
from typing import Optional
|
4
5
|
|
@@ -182,6 +183,11 @@ if "--use-file-pg-uri" in sys.argv:
|
|
182
183
|
pass
|
183
184
|
|
184
185
|
|
186
|
+
class DatabaseChoice(str, Enum):
|
187
|
+
POSTGRES = "postgres"
|
188
|
+
SQLITE = "sqlite"
|
189
|
+
|
190
|
+
|
185
191
|
class Settings(BaseSettings):
|
186
192
|
model_config = SettingsConfigDict(env_prefix="letta_", extra="ignore")
|
187
193
|
|
@@ -220,21 +226,24 @@ class Settings(BaseSettings):
|
|
220
226
|
multi_agent_concurrent_sends: int = 50
|
221
227
|
|
222
228
|
# telemetry logging
|
223
|
-
otel_exporter_otlp_endpoint:
|
224
|
-
otel_preferred_temporality:
|
229
|
+
otel_exporter_otlp_endpoint: str | None = None # otel default: "http://localhost:4317"
|
230
|
+
otel_preferred_temporality: int | None = Field(
|
225
231
|
default=1, ge=0, le=2, description="Exported metric temporality. {0: UNSPECIFIED, 1: DELTA, 2: CUMULATIVE}"
|
226
232
|
)
|
227
233
|
disable_tracing: bool = Field(default=False, description="Disable OTEL Tracing")
|
228
234
|
llm_api_logging: bool = Field(default=True, description="Enable LLM API logging at each step")
|
229
235
|
track_last_agent_run: bool = Field(default=False, description="Update last agent run metrics")
|
236
|
+
track_errored_messages: bool = Field(default=True, description="Enable tracking for errored messages")
|
237
|
+
track_stop_reason: bool = Field(default=True, description="Enable tracking stop reason on steps.")
|
238
|
+
track_agent_run: bool = Field(default=True, description="Enable tracking agent run with cancellation support")
|
230
239
|
|
231
|
-
#
|
240
|
+
# FastAPI Application Settings
|
232
241
|
uvicorn_workers: int = 1
|
233
242
|
uvicorn_reload: bool = False
|
234
243
|
uvicorn_timeout_keep_alive: int = 5
|
235
244
|
|
236
|
-
use_uvloop: bool =
|
237
|
-
use_granian: bool = False
|
245
|
+
use_uvloop: bool = Field(default=True, description="Enable uvloop as asyncio event loop.")
|
246
|
+
use_granian: bool = Field(default=False, description="Use Granian for workers")
|
238
247
|
sqlalchemy_tracing: bool = False
|
239
248
|
|
240
249
|
# event loop parallelism
|
@@ -244,6 +253,10 @@ class Settings(BaseSettings):
|
|
244
253
|
use_experimental: bool = False
|
245
254
|
use_vertex_structured_outputs_experimental: bool = False
|
246
255
|
|
256
|
+
# Database pool monitoring
|
257
|
+
enable_db_pool_monitoring: bool = True # Enable connection pool monitoring
|
258
|
+
db_pool_monitoring_interval: int = 30 # Seconds between pool stats collection
|
259
|
+
|
247
260
|
# cron job parameters
|
248
261
|
enable_batch_job_polling: bool = False
|
249
262
|
poll_running_llm_batches_interval_seconds: int = 5 * 60
|
@@ -272,7 +285,7 @@ class Settings(BaseSettings):
|
|
272
285
|
elif self.pg_db and self.pg_user and self.pg_password and self.pg_host and self.pg_port:
|
273
286
|
return f"postgresql+pg8000://{self.pg_user}:{self.pg_password}@{self.pg_host}:{self.pg_port}/{self.pg_db}"
|
274
287
|
else:
|
275
|
-
return
|
288
|
+
return "postgresql+pg8000://letta:letta@localhost:5432/letta"
|
276
289
|
|
277
290
|
# add this property to avoid being returned the default
|
278
291
|
# reference: https://github.com/letta-ai/letta/issues/1362
|
@@ -285,6 +298,10 @@ class Settings(BaseSettings):
|
|
285
298
|
else:
|
286
299
|
return None
|
287
300
|
|
301
|
+
@property
|
302
|
+
def database_engine(self) -> DatabaseChoice:
|
303
|
+
return DatabaseChoice.POSTGRES if self.letta_pg_uri_no_default else DatabaseChoice.SQLITE
|
304
|
+
|
288
305
|
@property
|
289
306
|
def plugin_register_dict(self) -> dict:
|
290
307
|
plugins = {}
|
letta/system.py
CHANGED
@@ -66,7 +66,7 @@ def get_initial_boot_messages(version, timezone):
|
|
66
66
|
"type": "function",
|
67
67
|
"function": {
|
68
68
|
"name": "send_message",
|
69
|
-
"arguments": '{\n "message": "' +
|
69
|
+
"arguments": '{\n "message": "' + "Hi, is anyone there?" + '"\n}',
|
70
70
|
},
|
71
71
|
}
|
72
72
|
],
|