letta-nightly 0.8.8.dev20250703104323__py3-none-any.whl → 0.8.8.dev20250703174903__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/agent.py +1 -0
- letta/agents/base_agent.py +8 -2
- letta/agents/ephemeral_summary_agent.py +33 -33
- letta/agents/letta_agent.py +104 -53
- letta/agents/voice_agent.py +2 -1
- letta/constants.py +8 -4
- letta/functions/function_sets/files.py +22 -7
- letta/functions/function_sets/multi_agent.py +34 -0
- letta/functions/types.py +1 -1
- letta/groups/helpers.py +8 -5
- letta/groups/sleeptime_multi_agent_v2.py +20 -15
- letta/interface.py +1 -1
- letta/interfaces/anthropic_streaming_interface.py +15 -8
- letta/interfaces/openai_chat_completions_streaming_interface.py +9 -6
- letta/interfaces/openai_streaming_interface.py +17 -11
- letta/llm_api/openai_client.py +2 -1
- letta/orm/agent.py +1 -0
- letta/orm/file.py +8 -2
- letta/orm/files_agents.py +36 -11
- letta/orm/mcp_server.py +3 -0
- letta/orm/source.py +2 -1
- letta/orm/step.py +3 -0
- letta/prompts/system/memgpt_v2_chat.txt +5 -8
- letta/schemas/agent.py +58 -23
- letta/schemas/embedding_config.py +3 -2
- letta/schemas/enums.py +4 -0
- letta/schemas/file.py +1 -0
- letta/schemas/letta_stop_reason.py +18 -0
- letta/schemas/mcp.py +15 -10
- letta/schemas/memory.py +35 -5
- letta/schemas/providers.py +11 -0
- letta/schemas/step.py +1 -0
- letta/schemas/tool.py +2 -1
- letta/server/rest_api/routers/v1/agents.py +320 -184
- letta/server/rest_api/routers/v1/groups.py +6 -2
- letta/server/rest_api/routers/v1/identities.py +6 -2
- letta/server/rest_api/routers/v1/jobs.py +49 -1
- letta/server/rest_api/routers/v1/sources.py +28 -19
- letta/server/rest_api/routers/v1/steps.py +7 -2
- letta/server/rest_api/routers/v1/tools.py +40 -9
- letta/server/rest_api/streaming_response.py +88 -0
- letta/server/server.py +61 -55
- letta/services/agent_manager.py +28 -16
- letta/services/file_manager.py +58 -9
- letta/services/file_processor/chunker/llama_index_chunker.py +2 -0
- letta/services/file_processor/embedder/openai_embedder.py +54 -10
- letta/services/file_processor/file_processor.py +59 -0
- letta/services/file_processor/parser/mistral_parser.py +2 -0
- letta/services/files_agents_manager.py +120 -2
- letta/services/helpers/agent_manager_helper.py +21 -4
- letta/services/job_manager.py +57 -6
- letta/services/mcp/base_client.py +1 -0
- letta/services/mcp_manager.py +13 -1
- letta/services/step_manager.py +14 -5
- letta/services/summarizer/summarizer.py +6 -22
- letta/services/tool_executor/builtin_tool_executor.py +0 -1
- letta/services/tool_executor/files_tool_executor.py +2 -2
- letta/services/tool_executor/multi_agent_tool_executor.py +23 -0
- letta/services/tool_manager.py +7 -7
- letta/settings.py +11 -2
- letta/templates/summary_request_text.j2 +19 -0
- letta/utils.py +95 -14
- {letta_nightly-0.8.8.dev20250703104323.dist-info → letta_nightly-0.8.8.dev20250703174903.dist-info}/METADATA +2 -2
- {letta_nightly-0.8.8.dev20250703104323.dist-info → letta_nightly-0.8.8.dev20250703174903.dist-info}/RECORD +68 -67
- /letta/{agents/prompts → prompts/system}/summary_system_prompt.txt +0 -0
- {letta_nightly-0.8.8.dev20250703104323.dist-info → letta_nightly-0.8.8.dev20250703174903.dist-info}/LICENSE +0 -0
- {letta_nightly-0.8.8.dev20250703104323.dist-info → letta_nightly-0.8.8.dev20250703174903.dist-info}/WHEEL +0 -0
- {letta_nightly-0.8.8.dev20250703104323.dist-info → letta_nightly-0.8.8.dev20250703174903.dist-info}/entry_points.txt +0 -0
@@ -4,15 +4,19 @@ from typing import List, Optional
|
|
4
4
|
from sqlalchemy import and_, func, select, update
|
5
5
|
|
6
6
|
from letta.constants import MAX_FILES_OPEN
|
7
|
+
from letta.log import get_logger
|
7
8
|
from letta.orm.errors import NoResultFound
|
8
9
|
from letta.orm.files_agents import FileAgent as FileAgentModel
|
9
10
|
from letta.otel.tracing import trace_method
|
10
11
|
from letta.schemas.block import Block as PydanticBlock
|
11
12
|
from letta.schemas.file import FileAgent as PydanticFileAgent
|
13
|
+
from letta.schemas.file import FileMetadata
|
12
14
|
from letta.schemas.user import User as PydanticUser
|
13
15
|
from letta.server.db import db_registry
|
14
16
|
from letta.utils import enforce_types
|
15
17
|
|
18
|
+
logger = get_logger(__name__)
|
19
|
+
|
16
20
|
|
17
21
|
class FileAgentManager:
|
18
22
|
"""High-level helpers for CRUD / listing on the `files_agents` join table."""
|
@@ -165,17 +169,19 @@ class FileAgentManager:
|
|
165
169
|
self,
|
166
170
|
*,
|
167
171
|
file_names: List[str],
|
172
|
+
agent_id: str,
|
168
173
|
actor: PydanticUser,
|
169
174
|
) -> List[PydanticBlock]:
|
170
175
|
"""
|
171
|
-
Retrieve multiple FileAgent associations by their
|
176
|
+
Retrieve multiple FileAgent associations by their file names for a specific agent.
|
172
177
|
|
173
178
|
Args:
|
174
179
|
file_names: List of file names to retrieve
|
180
|
+
agent_id: ID of the agent to retrieve file blocks for
|
175
181
|
actor: The user making the request
|
176
182
|
|
177
183
|
Returns:
|
178
|
-
List of
|
184
|
+
List of PydanticBlock objects found (may be fewer than requested if some file names don't exist)
|
179
185
|
"""
|
180
186
|
if not file_names:
|
181
187
|
return []
|
@@ -185,6 +191,7 @@ class FileAgentManager:
|
|
185
191
|
query = select(FileAgentModel).where(
|
186
192
|
and_(
|
187
193
|
FileAgentModel.file_name.in_(file_names),
|
194
|
+
FileAgentModel.agent_id == agent_id,
|
188
195
|
FileAgentModel.organization_id == actor.organization_id,
|
189
196
|
)
|
190
197
|
)
|
@@ -420,6 +427,117 @@ class FileAgentManager:
|
|
420
427
|
|
421
428
|
return closed_file_names, file_was_already_open
|
422
429
|
|
430
|
+
@enforce_types
|
431
|
+
@trace_method
|
432
|
+
async def attach_files_bulk(
|
433
|
+
self,
|
434
|
+
*,
|
435
|
+
agent_id: str,
|
436
|
+
files_metadata: list[FileMetadata],
|
437
|
+
visible_content_map: Optional[dict[str, str]] = None,
|
438
|
+
actor: PydanticUser,
|
439
|
+
) -> list[str]:
|
440
|
+
"""Atomically attach many files, applying an LRU cap with one commit."""
|
441
|
+
if not files_metadata:
|
442
|
+
return []
|
443
|
+
|
444
|
+
# TODO: This is not strictly necessary, as the file_metadata should never be duped
|
445
|
+
# TODO: But we have this as a protection, check logs for details
|
446
|
+
# dedupe while preserving caller order
|
447
|
+
seen: set[str] = set()
|
448
|
+
ordered_unique: list[FileMetadata] = []
|
449
|
+
for m in files_metadata:
|
450
|
+
if m.file_name not in seen:
|
451
|
+
ordered_unique.append(m)
|
452
|
+
seen.add(m.file_name)
|
453
|
+
if (dup_cnt := len(files_metadata) - len(ordered_unique)) > 0:
|
454
|
+
logger.warning(
|
455
|
+
"attach_files_bulk: removed %d duplicate file(s) for agent %s",
|
456
|
+
dup_cnt,
|
457
|
+
agent_id,
|
458
|
+
)
|
459
|
+
|
460
|
+
now = datetime.now(timezone.utc)
|
461
|
+
vc_for = visible_content_map or {}
|
462
|
+
|
463
|
+
async with db_registry.async_session() as session:
|
464
|
+
# fetch existing assoc rows for requested names
|
465
|
+
existing_q = select(FileAgentModel).where(
|
466
|
+
FileAgentModel.agent_id == agent_id,
|
467
|
+
FileAgentModel.organization_id == actor.organization_id,
|
468
|
+
FileAgentModel.file_name.in_(seen),
|
469
|
+
)
|
470
|
+
existing_rows = (await session.execute(existing_q)).scalars().all()
|
471
|
+
existing_by_name = {r.file_name: r for r in existing_rows}
|
472
|
+
|
473
|
+
# snapshot current OPEN rows (oldest first)
|
474
|
+
open_q = (
|
475
|
+
select(FileAgentModel)
|
476
|
+
.where(
|
477
|
+
FileAgentModel.agent_id == agent_id,
|
478
|
+
FileAgentModel.organization_id == actor.organization_id,
|
479
|
+
FileAgentModel.is_open.is_(True),
|
480
|
+
)
|
481
|
+
.order_by(FileAgentModel.last_accessed_at.asc())
|
482
|
+
)
|
483
|
+
currently_open = (await session.execute(open_q)).scalars().all()
|
484
|
+
|
485
|
+
new_names = [m.file_name for m in ordered_unique]
|
486
|
+
new_names_set = set(new_names)
|
487
|
+
still_open_names = [r.file_name for r in currently_open if r.file_name not in new_names_set]
|
488
|
+
|
489
|
+
# decide final open set
|
490
|
+
if len(new_names) >= MAX_FILES_OPEN:
|
491
|
+
final_open = new_names[:MAX_FILES_OPEN]
|
492
|
+
else:
|
493
|
+
room_for_old = MAX_FILES_OPEN - len(new_names)
|
494
|
+
final_open = new_names + still_open_names[-room_for_old:]
|
495
|
+
final_open_set = set(final_open)
|
496
|
+
|
497
|
+
closed_file_names = [r.file_name for r in currently_open if r.file_name not in final_open_set]
|
498
|
+
# Add new files that won't be opened due to MAX_FILES_OPEN limit
|
499
|
+
if len(new_names) >= MAX_FILES_OPEN:
|
500
|
+
closed_file_names.extend(new_names[MAX_FILES_OPEN:])
|
501
|
+
evicted_ids = [r.file_id for r in currently_open if r.file_name in closed_file_names]
|
502
|
+
|
503
|
+
# upsert requested files
|
504
|
+
for meta in ordered_unique:
|
505
|
+
is_now_open = meta.file_name in final_open_set
|
506
|
+
vc = vc_for.get(meta.file_name, "") if is_now_open else None
|
507
|
+
|
508
|
+
if row := existing_by_name.get(meta.file_name):
|
509
|
+
row.is_open = is_now_open
|
510
|
+
row.visible_content = vc
|
511
|
+
row.last_accessed_at = now
|
512
|
+
session.add(row) # already present, but safe
|
513
|
+
else:
|
514
|
+
session.add(
|
515
|
+
FileAgentModel(
|
516
|
+
agent_id=agent_id,
|
517
|
+
file_id=meta.id,
|
518
|
+
file_name=meta.file_name,
|
519
|
+
organization_id=actor.organization_id,
|
520
|
+
is_open=is_now_open,
|
521
|
+
visible_content=vc,
|
522
|
+
last_accessed_at=now,
|
523
|
+
)
|
524
|
+
)
|
525
|
+
|
526
|
+
# bulk-close evicted rows
|
527
|
+
if evicted_ids:
|
528
|
+
await session.execute(
|
529
|
+
update(FileAgentModel)
|
530
|
+
.where(
|
531
|
+
FileAgentModel.agent_id == agent_id,
|
532
|
+
FileAgentModel.organization_id == actor.organization_id,
|
533
|
+
FileAgentModel.file_id.in_(evicted_ids),
|
534
|
+
)
|
535
|
+
.values(is_open=False, visible_content=None)
|
536
|
+
)
|
537
|
+
|
538
|
+
await session.commit()
|
539
|
+
return closed_file_names
|
540
|
+
|
423
541
|
async def _get_association_by_file_id(self, session, agent_id: str, file_id: str, actor: PydanticUser) -> FileAgentModel:
|
424
542
|
q = select(FileAgentModel).where(
|
425
543
|
and_(
|
@@ -1,3 +1,4 @@
|
|
1
|
+
import os
|
1
2
|
from datetime import datetime
|
2
3
|
from typing import List, Literal, Optional, Set
|
3
4
|
|
@@ -10,9 +11,11 @@ from letta.constants import (
|
|
10
11
|
BASE_MEMORY_TOOLS,
|
11
12
|
BASE_MEMORY_TOOLS_V2,
|
12
13
|
BASE_TOOLS,
|
13
|
-
|
14
|
+
DEPRECATED_LETTA_TOOLS,
|
14
15
|
IN_CONTEXT_MEMORY_KEYWORD,
|
16
|
+
LOCAL_ONLY_MULTI_AGENT_TOOLS,
|
15
17
|
MAX_EMBEDDING_DIM,
|
18
|
+
MULTI_AGENT_TOOLS,
|
16
19
|
STRUCTURED_OUTPUT_MODELS,
|
17
20
|
)
|
18
21
|
from letta.embeddings import embedding_model
|
@@ -248,6 +251,7 @@ def compile_system_message(
|
|
248
251
|
previous_message_count: int = 0,
|
249
252
|
archival_memory_size: int = 0,
|
250
253
|
tool_rules_solver: Optional[ToolRulesSolver] = None,
|
254
|
+
sources: Optional[List] = None,
|
251
255
|
) -> str:
|
252
256
|
"""Prepare the final/full system message that will be fed into the LLM API
|
253
257
|
|
@@ -256,6 +260,7 @@ def compile_system_message(
|
|
256
260
|
The following are reserved variables:
|
257
261
|
- CORE_MEMORY: the in-context memory of the LLM
|
258
262
|
"""
|
263
|
+
|
259
264
|
# Add tool rule constraints if available
|
260
265
|
tool_constraint_block = None
|
261
266
|
if tool_rules_solver is not None:
|
@@ -278,13 +283,16 @@ def compile_system_message(
|
|
278
283
|
archival_memory_size=archival_memory_size,
|
279
284
|
timezone=timezone,
|
280
285
|
)
|
281
|
-
|
286
|
+
|
287
|
+
memory_with_sources = in_context_memory.compile(tool_usage_rules=tool_constraint_block, sources=sources)
|
288
|
+
full_memory_string = memory_with_sources + "\n\n" + memory_metadata_string
|
282
289
|
|
283
290
|
# Add to the variables list to inject
|
284
291
|
variables[IN_CONTEXT_MEMORY_KEYWORD] = full_memory_string
|
285
292
|
|
286
293
|
if template_format == "f-string":
|
287
294
|
memory_variable_string = "{" + IN_CONTEXT_MEMORY_KEYWORD + "}"
|
295
|
+
|
288
296
|
# Catch the special case where the system prompt is unformatted
|
289
297
|
if append_icm_if_missing:
|
290
298
|
if memory_variable_string not in system_prompt:
|
@@ -327,6 +335,7 @@ def initialize_message_sequence(
|
|
327
335
|
append_icm_if_missing=True,
|
328
336
|
previous_message_count=previous_message_count,
|
329
337
|
archival_memory_size=archival_memory_size,
|
338
|
+
sources=agent_state.sources,
|
330
339
|
)
|
331
340
|
first_user_message = get_login_event(agent_state.timezone) # event letting Letta know the user just logged in
|
332
341
|
|
@@ -1050,6 +1059,14 @@ def build_agent_passage_query(
|
|
1050
1059
|
|
1051
1060
|
def calculate_base_tools(is_v2: bool) -> Set[str]:
|
1052
1061
|
if is_v2:
|
1053
|
-
return (set(BASE_TOOLS) - set(
|
1062
|
+
return (set(BASE_TOOLS) - set(DEPRECATED_LETTA_TOOLS)) | set(BASE_MEMORY_TOOLS_V2)
|
1063
|
+
else:
|
1064
|
+
return (set(BASE_TOOLS) - set(DEPRECATED_LETTA_TOOLS)) | set(BASE_MEMORY_TOOLS)
|
1065
|
+
|
1066
|
+
|
1067
|
+
def calculate_multi_agent_tools() -> Set[str]:
|
1068
|
+
"""Calculate multi-agent tools, excluding local-only tools in production environment."""
|
1069
|
+
if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION":
|
1070
|
+
return set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS)
|
1054
1071
|
else:
|
1055
|
-
return
|
1072
|
+
return set(MULTI_AGENT_TOOLS)
|
letta/services/job_manager.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
from functools import reduce
|
1
|
+
from functools import partial, reduce
|
2
2
|
from operator import add
|
3
3
|
from typing import List, Literal, Optional, Union
|
4
4
|
|
@@ -14,7 +14,7 @@ from letta.orm.message import Message as MessageModel
|
|
14
14
|
from letta.orm.sqlalchemy_base import AccessType
|
15
15
|
from letta.orm.step import Step
|
16
16
|
from letta.orm.step import Step as StepModel
|
17
|
-
from letta.otel.tracing import trace_method
|
17
|
+
from letta.otel.tracing import log_event, trace_method
|
18
18
|
from letta.schemas.enums import JobStatus, JobType, MessageRole
|
19
19
|
from letta.schemas.job import BatchJob as PydanticBatchJob
|
20
20
|
from letta.schemas.job import Job as PydanticJob
|
@@ -98,7 +98,6 @@ class JobManager:
|
|
98
98
|
async with db_registry.async_session() as session:
|
99
99
|
# Fetch the job by ID
|
100
100
|
job = await self._verify_job_access_async(session=session, job_id=job_id, actor=actor, access=["write"])
|
101
|
-
not_completed_before = not bool(job.completed_at)
|
102
101
|
|
103
102
|
# Update job attributes with only the fields that were explicitly set
|
104
103
|
update_data = job_update.model_dump(to_orm=True, exclude_unset=True, exclude_none=True)
|
@@ -110,16 +109,62 @@ class JobManager:
|
|
110
109
|
value = value.replace(tzinfo=None)
|
111
110
|
setattr(job, key, value)
|
112
111
|
|
113
|
-
|
112
|
+
# If we are updating the job to a terminal state
|
113
|
+
if job_update.status in {JobStatus.completed, JobStatus.failed}:
|
114
|
+
logger.info(f"Current job completed at: {job.completed_at}")
|
114
115
|
job.completed_at = get_utc_time().replace(tzinfo=None)
|
115
116
|
if job.callback_url:
|
116
117
|
await self._dispatch_callback_async(job)
|
118
|
+
else:
|
119
|
+
logger.info(f"Job does not contain callback url: {job}")
|
120
|
+
else:
|
121
|
+
logger.info(f"Job update is not terminal {job_update}")
|
117
122
|
|
118
123
|
# Save the updated job to the database
|
119
124
|
await job.update_async(db_session=session, actor=actor)
|
120
125
|
|
121
126
|
return job.to_pydantic()
|
122
127
|
|
128
|
+
@enforce_types
|
129
|
+
@trace_method
|
130
|
+
async def safe_update_job_status_async(
|
131
|
+
self, job_id: str, new_status: JobStatus, actor: PydanticUser, metadata: Optional[dict] = None
|
132
|
+
) -> bool:
|
133
|
+
"""
|
134
|
+
Safely update job status with state transition guards.
|
135
|
+
Created -> Pending -> Running --> <Terminal>
|
136
|
+
|
137
|
+
Returns:
|
138
|
+
True if update was successful, False if update was skipped due to invalid transition
|
139
|
+
"""
|
140
|
+
try:
|
141
|
+
# Get current job state
|
142
|
+
current_job = await self.get_job_by_id_async(job_id=job_id, actor=actor)
|
143
|
+
|
144
|
+
current_status = current_job.status
|
145
|
+
if not any(
|
146
|
+
(
|
147
|
+
new_status.is_terminal and not current_status.is_terminal,
|
148
|
+
current_status == JobStatus.created and new_status != JobStatus.created,
|
149
|
+
current_status == JobStatus.pending and new_status == JobStatus.running,
|
150
|
+
)
|
151
|
+
):
|
152
|
+
logger.warning(f"Invalid job status transition from {current_job.status} to {new_status} for job {job_id}")
|
153
|
+
return False
|
154
|
+
|
155
|
+
job_update_builder = partial(JobUpdate, status=new_status)
|
156
|
+
if metadata:
|
157
|
+
job_update_builder = partial(job_update_builder, metadata=metadata)
|
158
|
+
if new_status.is_terminal:
|
159
|
+
job_update_builder = partial(job_update_builder, completed_at=get_utc_time())
|
160
|
+
|
161
|
+
await self.update_job_by_id_async(job_id=job_id, job_update=job_update_builder(), actor=actor)
|
162
|
+
return True
|
163
|
+
|
164
|
+
except Exception as e:
|
165
|
+
logger.error(f"Failed to safely update job status for job {job_id}: {e}")
|
166
|
+
return False
|
167
|
+
|
123
168
|
@enforce_types
|
124
169
|
@trace_method
|
125
170
|
def get_job_by_id(self, job_id: str, actor: PydanticUser) -> PydanticJob:
|
@@ -628,6 +673,7 @@ class JobManager:
|
|
628
673
|
request_config = job.request_config or LettaRequestConfig()
|
629
674
|
return request_config
|
630
675
|
|
676
|
+
@trace_method
|
631
677
|
def _dispatch_callback(self, job: JobModel) -> None:
|
632
678
|
"""
|
633
679
|
POST a standard JSON payload to job.callback_url
|
@@ -643,18 +689,21 @@ class JobManager:
|
|
643
689
|
try:
|
644
690
|
import httpx
|
645
691
|
|
692
|
+
log_event("POST callback dispatched", payload)
|
646
693
|
resp = httpx.post(job.callback_url, json=payload, timeout=5.0)
|
694
|
+
log_event("POST callback finished")
|
647
695
|
job.callback_sent_at = get_utc_time().replace(tzinfo=None)
|
648
696
|
job.callback_status_code = resp.status_code
|
649
697
|
|
650
698
|
except Exception as e:
|
651
|
-
error_message = f"Failed to dispatch callback for job {job.id} to {job.callback_url}: {
|
699
|
+
error_message = f"Failed to dispatch callback for job {job.id} to {job.callback_url}: {e!s}"
|
652
700
|
logger.error(error_message)
|
653
701
|
# Record the failed attempt
|
654
702
|
job.callback_sent_at = get_utc_time().replace(tzinfo=None)
|
655
703
|
job.callback_error = error_message
|
656
704
|
# Continue silently - callback failures should not affect job completion
|
657
705
|
|
706
|
+
@trace_method
|
658
707
|
async def _dispatch_callback_async(self, job: JobModel) -> None:
|
659
708
|
"""
|
660
709
|
POST a standard JSON payload to job.callback_url and record timestamp + HTTP status asynchronously.
|
@@ -670,12 +719,14 @@ class JobManager:
|
|
670
719
|
import httpx
|
671
720
|
|
672
721
|
async with httpx.AsyncClient() as client:
|
722
|
+
log_event("POST callback dispatched", payload)
|
673
723
|
resp = await client.post(job.callback_url, json=payload, timeout=5.0)
|
724
|
+
log_event("POST callback finished")
|
674
725
|
# Ensure timestamp is timezone-naive for DB compatibility
|
675
726
|
job.callback_sent_at = get_utc_time().replace(tzinfo=None)
|
676
727
|
job.callback_status_code = resp.status_code
|
677
728
|
except Exception as e:
|
678
|
-
error_message = f"Failed to dispatch callback for job {job.id} to {job.callback_url}: {
|
729
|
+
error_message = f"Failed to dispatch callback for job {job.id} to {job.callback_url}: {e!s}"
|
679
730
|
logger.error(error_message)
|
680
731
|
# Record the failed attempt
|
681
732
|
job.callback_sent_at = get_utc_time().replace(tzinfo=None)
|
@@ -77,6 +77,7 @@ class AsyncBaseMCPClient:
|
|
77
77
|
logger.error("MCPClient has not been initialized")
|
78
78
|
raise RuntimeError("MCPClient has not been initialized")
|
79
79
|
|
80
|
+
# TODO: still hitting some async errors for voice agents, need to fix
|
80
81
|
async def cleanup(self):
|
81
82
|
"""Clean up resources - ensure this runs in the same task"""
|
82
83
|
if hasattr(self, "_cleanup_task"):
|
letta/services/mcp_manager.py
CHANGED
@@ -2,6 +2,8 @@ import json
|
|
2
2
|
import os
|
3
3
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
4
4
|
|
5
|
+
from sqlalchemy import null
|
6
|
+
|
5
7
|
import letta.constants as constants
|
6
8
|
from letta.functions.mcp_client.types import MCPServerType, MCPTool, SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig
|
7
9
|
from letta.log import get_logger
|
@@ -156,6 +158,10 @@ class MCPManager:
|
|
156
158
|
pydantic_mcp_server.organization_id = actor.organization_id
|
157
159
|
mcp_server_data = pydantic_mcp_server.model_dump(to_orm=True)
|
158
160
|
|
161
|
+
# Ensure custom_headers None is stored as SQL NULL, not JSON null
|
162
|
+
if mcp_server_data.get("custom_headers") is None:
|
163
|
+
mcp_server_data.pop("custom_headers", None)
|
164
|
+
|
159
165
|
mcp_server = MCPServerModel(**mcp_server_data)
|
160
166
|
mcp_server = await mcp_server.create_async(session, actor=actor)
|
161
167
|
return mcp_server.to_pydantic()
|
@@ -168,7 +174,13 @@ class MCPManager:
|
|
168
174
|
mcp_server = await MCPServerModel.read_async(db_session=session, identifier=mcp_server_id, actor=actor)
|
169
175
|
|
170
176
|
# Update tool attributes with only the fields that were explicitly set
|
171
|
-
update_data = mcp_server_update.model_dump(to_orm=True,
|
177
|
+
update_data = mcp_server_update.model_dump(to_orm=True, exclude_unset=True)
|
178
|
+
|
179
|
+
# Ensure custom_headers None is stored as SQL NULL, not JSON null
|
180
|
+
if update_data.get("custom_headers") is None:
|
181
|
+
update_data.pop("custom_headers", None)
|
182
|
+
setattr(mcp_server, "custom_headers", null())
|
183
|
+
|
172
184
|
for key, value in update_data.items():
|
173
185
|
setattr(mcp_server, key, value)
|
174
186
|
|
letta/services/step_manager.py
CHANGED
@@ -42,6 +42,7 @@ class StepManager:
|
|
42
42
|
trace_ids: Optional[list[str]] = None,
|
43
43
|
feedback: Optional[Literal["positive", "negative"]] = None,
|
44
44
|
has_feedback: Optional[bool] = None,
|
45
|
+
project_id: Optional[str] = None,
|
45
46
|
) -> List[PydanticStep]:
|
46
47
|
"""List all jobs with optional pagination and status filter."""
|
47
48
|
async with db_registry.async_session() as session:
|
@@ -54,6 +55,8 @@ class StepManager:
|
|
54
55
|
filter_kwargs["trace_id"] = trace_ids
|
55
56
|
if feedback:
|
56
57
|
filter_kwargs["feedback"] = feedback
|
58
|
+
if project_id:
|
59
|
+
filter_kwargs["project_id"] = project_id
|
57
60
|
steps = await StepModel.list_async(
|
58
61
|
db_session=session,
|
59
62
|
before=before,
|
@@ -82,6 +85,7 @@ class StepManager:
|
|
82
85
|
provider_id: Optional[str] = None,
|
83
86
|
job_id: Optional[str] = None,
|
84
87
|
step_id: Optional[str] = None,
|
88
|
+
project_id: Optional[str] = None,
|
85
89
|
) -> PydanticStep:
|
86
90
|
step_data = {
|
87
91
|
"origin": None,
|
@@ -100,6 +104,7 @@ class StepManager:
|
|
100
104
|
"tags": [],
|
101
105
|
"tid": None,
|
102
106
|
"trace_id": get_trace_id(), # Get the current trace ID
|
107
|
+
"project_id": project_id,
|
103
108
|
}
|
104
109
|
if step_id:
|
105
110
|
step_data["id"] = step_id
|
@@ -125,6 +130,7 @@ class StepManager:
|
|
125
130
|
provider_id: Optional[str] = None,
|
126
131
|
job_id: Optional[str] = None,
|
127
132
|
step_id: Optional[str] = None,
|
133
|
+
project_id: Optional[str] = None,
|
128
134
|
) -> PydanticStep:
|
129
135
|
step_data = {
|
130
136
|
"origin": None,
|
@@ -143,6 +149,7 @@ class StepManager:
|
|
143
149
|
"tags": [],
|
144
150
|
"tid": None,
|
145
151
|
"trace_id": get_trace_id(), # Get the current trace ID
|
152
|
+
"project_id": project_id,
|
146
153
|
}
|
147
154
|
if step_id:
|
148
155
|
step_data["id"] = step_id
|
@@ -173,7 +180,7 @@ class StepManager:
|
|
173
180
|
|
174
181
|
@enforce_types
|
175
182
|
@trace_method
|
176
|
-
def update_step_transaction_id(self, actor: PydanticUser, step_id: str, transaction_id: str) -> PydanticStep:
|
183
|
+
async def update_step_transaction_id(self, actor: PydanticUser, step_id: str, transaction_id: str) -> PydanticStep:
|
177
184
|
"""Update the transaction ID for a step.
|
178
185
|
|
179
186
|
Args:
|
@@ -187,15 +194,15 @@ class StepManager:
|
|
187
194
|
Raises:
|
188
195
|
NoResultFound: If the step does not exist
|
189
196
|
"""
|
190
|
-
with db_registry.
|
191
|
-
step = session.get(StepModel, step_id)
|
197
|
+
async with db_registry.async_session() as session:
|
198
|
+
step = await session.get(StepModel, step_id)
|
192
199
|
if not step:
|
193
200
|
raise NoResultFound(f"Step with id {step_id} does not exist")
|
194
201
|
if step.organization_id != actor.organization_id:
|
195
202
|
raise Exception("Unauthorized")
|
196
203
|
|
197
204
|
step.tid = transaction_id
|
198
|
-
session.commit()
|
205
|
+
await session.commit()
|
199
206
|
return step.to_pydantic()
|
200
207
|
|
201
208
|
def _verify_job_access(
|
@@ -226,8 +233,8 @@ class StepManager:
|
|
226
233
|
raise NoResultFound(f"Job with id {job_id} does not exist or user does not have access")
|
227
234
|
return job
|
228
235
|
|
236
|
+
@staticmethod
|
229
237
|
async def _verify_job_access_async(
|
230
|
-
self,
|
231
238
|
session: AsyncSession,
|
232
239
|
job_id: str,
|
233
240
|
actor: PydanticUser,
|
@@ -280,6 +287,7 @@ class NoopStepManager(StepManager):
|
|
280
287
|
provider_id: Optional[str] = None,
|
281
288
|
job_id: Optional[str] = None,
|
282
289
|
step_id: Optional[str] = None,
|
290
|
+
project_id: Optional[str] = None,
|
283
291
|
) -> PydanticStep:
|
284
292
|
return
|
285
293
|
|
@@ -298,5 +306,6 @@ class NoopStepManager(StepManager):
|
|
298
306
|
provider_id: Optional[str] = None,
|
299
307
|
job_id: Optional[str] = None,
|
300
308
|
step_id: Optional[str] = None,
|
309
|
+
project_id: Optional[str] = None,
|
301
310
|
) -> PydanticStep:
|
302
311
|
return
|
@@ -11,6 +11,7 @@ from letta.schemas.enums import MessageRole
|
|
11
11
|
from letta.schemas.letta_message_content import TextContent
|
12
12
|
from letta.schemas.message import Message, MessageCreate
|
13
13
|
from letta.services.summarizer.enums import SummarizationMode
|
14
|
+
from letta.templates.template_helper import render_template
|
14
15
|
|
15
16
|
logger = get_logger(__name__)
|
16
17
|
|
@@ -123,30 +124,13 @@ class Summarizer:
|
|
123
124
|
formatted_evicted_messages = [f"{i}. {msg}" for (i, msg) in enumerate(formatted_evicted_messages)]
|
124
125
|
formatted_in_context_messages = [f"{i + offset}. {msg}" for (i, msg) in enumerate(formatted_in_context_messages)]
|
125
126
|
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
"Scan the conversation history, focusing on messages about to drop out of that window, "
|
132
|
-
"and write crisp notes that capture any important facts or insights about the conversation history so they aren’t lost."
|
127
|
+
summary_request_text = render_template(
|
128
|
+
"summary_request_text.j2",
|
129
|
+
retain_count=retain_count,
|
130
|
+
evicted_messages=formatted_evicted_messages,
|
131
|
+
in_context_messages=formatted_in_context_messages,
|
133
132
|
)
|
134
133
|
|
135
|
-
# Sections
|
136
|
-
evicted_section = f"\n\n(Older) Evicted Messages:\n{evicted_messages_str}" if evicted_messages_str.strip() else ""
|
137
|
-
in_context_section = ""
|
138
|
-
|
139
|
-
if retain_count > 0 and in_context_messages_str.strip():
|
140
|
-
in_context_section = f"\n\n(Newer) In-Context Messages:\n{in_context_messages_str}"
|
141
|
-
elif retain_count == 0:
|
142
|
-
prompt_header = (
|
143
|
-
"You’re a memory-recall helper for an AI that is about to forget all prior messages. "
|
144
|
-
"Scan the conversation history and write crisp notes that capture any important facts or insights about the conversation history."
|
145
|
-
)
|
146
|
-
|
147
|
-
# Compose final prompt
|
148
|
-
summary_request_text = prompt_header + evicted_section + in_context_section
|
149
|
-
|
150
134
|
# Fire-and-forget the summarization task
|
151
135
|
self.fire_and_forget(
|
152
136
|
self.summarizer_agent.step([MessageCreate(role=MessageRole.user, content=[TextContent(text=summary_request_text)])])
|
@@ -327,7 +327,6 @@ class LettaBuiltinToolExecutor(ToolExecutor):
|
|
327
327
|
messages=[{"role": "system", "content": FIRECRAWL_SEARCH_SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}],
|
328
328
|
response_format=DocumentAnalysis,
|
329
329
|
temperature=0.1,
|
330
|
-
max_tokens=300, # Limit output tokens - only need line numbers
|
331
330
|
)
|
332
331
|
|
333
332
|
end_time = time.time()
|
@@ -76,7 +76,7 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
76
76
|
function_map = {
|
77
77
|
"open_files": self.open_files,
|
78
78
|
"grep_files": self.grep_files,
|
79
|
-
"
|
79
|
+
"semantic_search_files": self.semantic_search_files,
|
80
80
|
}
|
81
81
|
|
82
82
|
if function_name not in function_map:
|
@@ -463,7 +463,7 @@ class LettaFileToolExecutor(ToolExecutor):
|
|
463
463
|
return "\n".join(formatted_results)
|
464
464
|
|
465
465
|
@trace_method
|
466
|
-
async def
|
466
|
+
async def semantic_search_files(self, agent_state: AgentState, query: str, limit: int = 10) -> str:
|
467
467
|
"""
|
468
468
|
Search for text within attached files using semantic search and return passages with their source filenames.
|
469
469
|
|
@@ -1,6 +1,8 @@
|
|
1
1
|
import asyncio
|
2
|
+
import os
|
2
3
|
from typing import Any, Dict, List, Optional
|
3
4
|
|
5
|
+
from letta.log import get_logger
|
4
6
|
from letta.schemas.agent import AgentState
|
5
7
|
from letta.schemas.enums import MessageRole
|
6
8
|
from letta.schemas.letta_message import AssistantMessage
|
@@ -12,6 +14,8 @@ from letta.schemas.tool_execution_result import ToolExecutionResult
|
|
12
14
|
from letta.schemas.user import User
|
13
15
|
from letta.services.tool_executor.tool_executor_base import ToolExecutor
|
14
16
|
|
17
|
+
logger = get_logger(__name__)
|
18
|
+
|
15
19
|
|
16
20
|
class LettaMultiAgentToolExecutor(ToolExecutor):
|
17
21
|
"""Executor for LETTA multi-agent core tools."""
|
@@ -29,6 +33,7 @@ class LettaMultiAgentToolExecutor(ToolExecutor):
|
|
29
33
|
assert agent_state is not None, "Agent state is required for multi-agent tools"
|
30
34
|
function_map = {
|
31
35
|
"send_message_to_agent_and_wait_for_reply": self.send_message_to_agent_and_wait_for_reply,
|
36
|
+
"send_message_to_agent_async": self.send_message_to_agent_async,
|
32
37
|
"send_message_to_agents_matching_tags": self.send_message_to_agents_matching_tags_async,
|
33
38
|
}
|
34
39
|
|
@@ -105,3 +110,21 @@ class LettaMultiAgentToolExecutor(ToolExecutor):
|
|
105
110
|
"error": str(e),
|
106
111
|
"type": type(e).__name__,
|
107
112
|
}
|
113
|
+
|
114
|
+
async def send_message_to_agent_async(self, agent_state: AgentState, message: str, other_agent_id: str) -> str:
|
115
|
+
if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION":
|
116
|
+
raise RuntimeError("This tool is not allowed to be run on Letta Cloud.")
|
117
|
+
|
118
|
+
# 1) Build the prefixed system‐message
|
119
|
+
prefixed = (
|
120
|
+
f"[Incoming message from agent with ID '{agent_state.id}' - "
|
121
|
+
f"to reply to this message, make sure to use the "
|
122
|
+
f"'send_message_to_agent_async' tool, or the agent will not receive your message] "
|
123
|
+
f"{message}"
|
124
|
+
)
|
125
|
+
|
126
|
+
task = asyncio.create_task(self._process_agent(agent_id=other_agent_id, message=prefixed))
|
127
|
+
|
128
|
+
task.add_done_callback(lambda t: (logger.error(f"Async send_message task failed: {t.exception()}") if t.exception() else None))
|
129
|
+
|
130
|
+
return "Successfully sent message"
|