letta-nightly 0.9.1.dev20250731104458__py3-none-any.whl → 0.10.0.dev20250801060805__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 +2 -1
- letta/agent.py +1 -1
- letta/agents/base_agent.py +2 -2
- letta/agents/letta_agent.py +22 -8
- letta/agents/letta_agent_batch.py +2 -2
- letta/agents/voice_agent.py +2 -2
- letta/client/client.py +0 -11
- letta/data_sources/redis_client.py +1 -2
- letta/errors.py +11 -0
- letta/functions/function_sets/builtin.py +3 -7
- letta/functions/mcp_client/types.py +107 -1
- letta/helpers/reasoning_helper.py +48 -0
- letta/helpers/tool_execution_helper.py +2 -65
- letta/interfaces/openai_streaming_interface.py +38 -2
- letta/llm_api/anthropic_client.py +1 -5
- letta/llm_api/google_vertex_client.py +1 -1
- letta/llm_api/llm_client.py +1 -1
- letta/llm_api/openai_client.py +2 -0
- letta/llm_api/sample_response_jsons/lmstudio_embedding_list.json +3 -2
- letta/orm/agent.py +5 -0
- letta/orm/enums.py +0 -1
- letta/orm/file.py +0 -1
- letta/orm/files_agents.py +9 -9
- letta/orm/sandbox_config.py +1 -1
- letta/orm/sqlite_functions.py +15 -13
- letta/prompts/system/memgpt_generate_tool.txt +139 -0
- letta/schemas/agent.py +15 -1
- letta/schemas/enums.py +6 -0
- letta/schemas/file.py +3 -3
- letta/schemas/letta_ping.py +28 -0
- letta/schemas/letta_request.py +9 -0
- letta/schemas/letta_stop_reason.py +25 -0
- letta/schemas/llm_config.py +1 -0
- letta/schemas/mcp.py +16 -3
- letta/schemas/memory.py +5 -0
- letta/schemas/providers/lmstudio.py +7 -0
- letta/schemas/providers/ollama.py +11 -8
- letta/schemas/sandbox_config.py +17 -7
- letta/server/rest_api/app.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +93 -30
- letta/server/rest_api/routers/v1/blocks.py +52 -0
- letta/server/rest_api/routers/v1/sandbox_configs.py +2 -1
- letta/server/rest_api/routers/v1/tools.py +43 -101
- letta/server/rest_api/streaming_response.py +121 -9
- letta/server/server.py +6 -10
- letta/services/agent_manager.py +41 -4
- letta/services/block_manager.py +63 -1
- letta/services/file_processor/chunker/line_chunker.py +20 -19
- letta/services/file_processor/file_processor.py +0 -2
- letta/services/file_processor/file_types.py +1 -2
- letta/services/files_agents_manager.py +46 -6
- letta/services/helpers/agent_manager_helper.py +185 -13
- letta/services/job_manager.py +4 -4
- letta/services/mcp/oauth_utils.py +6 -150
- letta/services/mcp_manager.py +120 -2
- letta/services/sandbox_config_manager.py +3 -5
- letta/services/tool_executor/builtin_tool_executor.py +13 -18
- letta/services/tool_executor/files_tool_executor.py +31 -27
- letta/services/tool_executor/mcp_tool_executor.py +10 -1
- letta/services/tool_executor/{tool_executor.py → sandbox_tool_executor.py} +14 -2
- letta/services/tool_executor/tool_execution_manager.py +1 -1
- letta/services/tool_executor/tool_execution_sandbox.py +2 -1
- letta/services/tool_manager.py +59 -21
- letta/services/tool_sandbox/base.py +18 -2
- letta/services/tool_sandbox/e2b_sandbox.py +5 -35
- letta/services/tool_sandbox/local_sandbox.py +5 -22
- letta/services/tool_sandbox/modal_sandbox.py +205 -0
- letta/settings.py +27 -8
- letta/system.py +1 -4
- letta/templates/template_helper.py +5 -0
- letta/utils.py +14 -2
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/METADATA +7 -3
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/RECORD +76 -73
- letta/orm/__all__.py +0 -15
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/LICENSE +0 -0
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/WHEEL +0 -0
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/entry_points.txt +0 -0
@@ -11,6 +11,7 @@ from starlette.types import Send
|
|
11
11
|
|
12
12
|
from letta.log import get_logger
|
13
13
|
from letta.schemas.enums import JobStatus
|
14
|
+
from letta.schemas.letta_ping import LettaPing
|
14
15
|
from letta.schemas.user import User
|
15
16
|
from letta.server.rest_api.utils import capture_sentry_exception
|
16
17
|
from letta.services.job_manager import JobManager
|
@@ -18,6 +19,88 @@ from letta.services.job_manager import JobManager
|
|
18
19
|
logger = get_logger(__name__)
|
19
20
|
|
20
21
|
|
22
|
+
class JobCancelledException(Exception):
|
23
|
+
"""Exception raised when a job is explicitly cancelled (not due to client timeout)"""
|
24
|
+
|
25
|
+
def __init__(self, job_id: str, message: str = None):
|
26
|
+
self.job_id = job_id
|
27
|
+
super().__init__(message or f"Job {job_id} was explicitly cancelled")
|
28
|
+
|
29
|
+
|
30
|
+
async def add_keepalive_to_stream(
|
31
|
+
stream_generator: AsyncIterator[str | bytes],
|
32
|
+
keepalive_interval: float = 30.0,
|
33
|
+
) -> AsyncIterator[str | bytes]:
|
34
|
+
"""
|
35
|
+
Adds periodic keepalive messages to a stream to prevent connection timeouts.
|
36
|
+
|
37
|
+
Sends a keepalive ping every `keepalive_interval` seconds, regardless of
|
38
|
+
whether data is flowing. This ensures connections stay alive during long
|
39
|
+
operations like tool execution.
|
40
|
+
|
41
|
+
Args:
|
42
|
+
stream_generator: The original stream generator to wrap
|
43
|
+
keepalive_interval: Seconds between keepalive messages (default: 30)
|
44
|
+
|
45
|
+
Yields:
|
46
|
+
Original stream chunks interspersed with keepalive messages
|
47
|
+
"""
|
48
|
+
# Use a queue to decouple the stream reading from keepalive timing
|
49
|
+
queue = asyncio.Queue()
|
50
|
+
stream_exhausted = False
|
51
|
+
|
52
|
+
async def stream_reader():
|
53
|
+
"""Read from the original stream and put items in the queue."""
|
54
|
+
nonlocal stream_exhausted
|
55
|
+
try:
|
56
|
+
async for item in stream_generator:
|
57
|
+
await queue.put(("data", item))
|
58
|
+
finally:
|
59
|
+
stream_exhausted = True
|
60
|
+
await queue.put(("end", None))
|
61
|
+
|
62
|
+
# Start the stream reader task
|
63
|
+
reader_task = asyncio.create_task(stream_reader())
|
64
|
+
|
65
|
+
try:
|
66
|
+
while True:
|
67
|
+
try:
|
68
|
+
# Wait for data with a timeout equal to keepalive interval
|
69
|
+
msg_type, data = await asyncio.wait_for(queue.get(), timeout=keepalive_interval)
|
70
|
+
|
71
|
+
if msg_type == "end":
|
72
|
+
# Stream finished
|
73
|
+
break
|
74
|
+
elif msg_type == "data":
|
75
|
+
yield data
|
76
|
+
|
77
|
+
except asyncio.TimeoutError:
|
78
|
+
# No data received within keepalive interval
|
79
|
+
if not stream_exhausted:
|
80
|
+
# Send keepalive ping in the same format as [DONE]
|
81
|
+
yield f"data: {LettaPing().model_dump_json()}\n\n"
|
82
|
+
else:
|
83
|
+
# Stream is done but queue might be processing
|
84
|
+
# Check if there's anything left
|
85
|
+
try:
|
86
|
+
msg_type, data = queue.get_nowait()
|
87
|
+
if msg_type == "end":
|
88
|
+
break
|
89
|
+
elif msg_type == "data":
|
90
|
+
yield data
|
91
|
+
except asyncio.QueueEmpty:
|
92
|
+
# Really done now
|
93
|
+
break
|
94
|
+
|
95
|
+
finally:
|
96
|
+
# Clean up the reader task
|
97
|
+
reader_task.cancel()
|
98
|
+
try:
|
99
|
+
await reader_task
|
100
|
+
except asyncio.CancelledError:
|
101
|
+
pass
|
102
|
+
|
103
|
+
|
21
104
|
# TODO (cliandy) wrap this and handle types
|
22
105
|
async def cancellation_aware_stream_wrapper(
|
23
106
|
stream_generator: AsyncIterator[str | bytes],
|
@@ -59,8 +142,8 @@ async def cancellation_aware_stream_wrapper(
|
|
59
142
|
# Send cancellation event to client
|
60
143
|
cancellation_event = {"message_type": "stop_reason", "stop_reason": "cancelled"}
|
61
144
|
yield f"data: {json.dumps(cancellation_event)}\n\n"
|
62
|
-
# Raise
|
63
|
-
raise
|
145
|
+
# Raise custom exception for explicit job cancellation
|
146
|
+
raise JobCancelledException(job_id, f"Job {job_id} was cancelled")
|
64
147
|
except Exception as e:
|
65
148
|
# Log warning but don't fail the stream if cancellation check fails
|
66
149
|
logger.warning(f"Failed to check job cancellation for job {job_id}: {e}")
|
@@ -69,9 +152,13 @@ async def cancellation_aware_stream_wrapper(
|
|
69
152
|
|
70
153
|
yield chunk
|
71
154
|
|
155
|
+
except JobCancelledException:
|
156
|
+
# Re-raise JobCancelledException to distinguish from client timeout
|
157
|
+
logger.info(f"Stream for job {job_id} was explicitly cancelled and cleaned up")
|
158
|
+
raise
|
72
159
|
except asyncio.CancelledError:
|
73
|
-
# Re-raise CancelledError to ensure proper cleanup
|
74
|
-
logger.info(f"Stream for job {job_id} was cancelled and cleaned up")
|
160
|
+
# Re-raise CancelledError (likely client timeout) to ensure proper cleanup
|
161
|
+
logger.info(f"Stream for job {job_id} was cancelled (likely client timeout) and cleaned up")
|
75
162
|
raise
|
76
163
|
except Exception as e:
|
77
164
|
logger.error(f"Error in cancellation-aware stream wrapper for job {job_id}: {e}")
|
@@ -140,12 +227,12 @@ class StreamingResponseWithStatusCode(StreamingResponse):
|
|
140
227
|
}
|
141
228
|
)
|
142
229
|
|
143
|
-
#
|
144
|
-
except
|
145
|
-
logger.
|
146
|
-
# Handle cancellation gracefully
|
230
|
+
# Handle explicit job cancellations (should not throw error)
|
231
|
+
except JobCancelledException as exc:
|
232
|
+
logger.info(f"Stream was explicitly cancelled for job {exc.job_id}")
|
233
|
+
# Handle explicit cancellation gracefully without error
|
147
234
|
more_body = False
|
148
|
-
cancellation_resp = {"
|
235
|
+
cancellation_resp = {"message": "Job was cancelled"}
|
149
236
|
cancellation_event = f"event: cancelled\ndata: {json.dumps(cancellation_resp)}\n\n".encode(self.charset)
|
150
237
|
if not self.response_started:
|
151
238
|
await send(
|
@@ -163,6 +250,31 @@ class StreamingResponseWithStatusCode(StreamingResponse):
|
|
163
250
|
"more_body": more_body,
|
164
251
|
}
|
165
252
|
)
|
253
|
+
return
|
254
|
+
|
255
|
+
# Handle client timeouts (should throw error to inform user)
|
256
|
+
except asyncio.CancelledError as exc:
|
257
|
+
logger.warning("Stream was cancelled due to client timeout or unexpected disconnection")
|
258
|
+
# Handle unexpected cancellation with error
|
259
|
+
more_body = False
|
260
|
+
error_resp = {"error": {"message": "Request was unexpectedly cancelled (likely due to client timeout or disconnection)"}}
|
261
|
+
error_event = f"event: error\ndata: {json.dumps(error_resp)}\n\n".encode(self.charset)
|
262
|
+
if not self.response_started:
|
263
|
+
await send(
|
264
|
+
{
|
265
|
+
"type": "http.response.start",
|
266
|
+
"status": 408, # Request Timeout
|
267
|
+
"headers": self.raw_headers,
|
268
|
+
}
|
269
|
+
)
|
270
|
+
raise
|
271
|
+
await send(
|
272
|
+
{
|
273
|
+
"type": "http.response.body",
|
274
|
+
"body": error_event,
|
275
|
+
"more_body": more_body,
|
276
|
+
}
|
277
|
+
)
|
166
278
|
capture_sentry_exception(exc)
|
167
279
|
return
|
168
280
|
|
letta/server/server.py
CHANGED
@@ -40,7 +40,7 @@ from letta.schemas.block import Block, BlockUpdate, CreateBlock
|
|
40
40
|
from letta.schemas.embedding_config import EmbeddingConfig
|
41
41
|
|
42
42
|
# openai schemas
|
43
|
-
from letta.schemas.enums import JobStatus, MessageStreamStatus, ProviderCategory, ProviderType
|
43
|
+
from letta.schemas.enums import JobStatus, MessageStreamStatus, ProviderCategory, ProviderType, SandboxType
|
44
44
|
from letta.schemas.environment_variables import SandboxEnvironmentVariableCreate
|
45
45
|
from letta.schemas.group import GroupCreate, ManagerType, SleeptimeManager, VoiceSleeptimeManager
|
46
46
|
from letta.schemas.job import Job, JobUpdate
|
@@ -67,9 +67,10 @@ from letta.schemas.providers import (
|
|
67
67
|
OpenAIProvider,
|
68
68
|
Provider,
|
69
69
|
TogetherProvider,
|
70
|
+
VLLMProvider,
|
70
71
|
XAIProvider,
|
71
72
|
)
|
72
|
-
from letta.schemas.sandbox_config import LocalSandboxConfig, SandboxConfigCreate
|
73
|
+
from letta.schemas.sandbox_config import LocalSandboxConfig, SandboxConfigCreate
|
73
74
|
from letta.schemas.source import Source
|
74
75
|
from letta.schemas.tool import Tool
|
75
76
|
from letta.schemas.usage import LettaUsageStatistics
|
@@ -361,22 +362,17 @@ class SyncServer(Server):
|
|
361
362
|
)
|
362
363
|
if model_settings.vllm_api_base:
|
363
364
|
# vLLM exposes both a /chat/completions and a /completions endpoint
|
364
|
-
self._enabled_providers.append(
|
365
|
-
VLLMCompletionsProvider(
|
366
|
-
name="vllm",
|
367
|
-
base_url=model_settings.vllm_api_base,
|
368
|
-
default_prompt_formatter=model_settings.default_prompt_formatter,
|
369
|
-
)
|
370
|
-
)
|
371
365
|
# NOTE: to use the /chat/completions endpoint, you need to specify extra flags on vLLM startup
|
372
366
|
# see: https://docs.vllm.ai/en/stable/features/tool_calling.html
|
373
367
|
# e.g. "... --enable-auto-tool-choice --tool-call-parser hermes"
|
374
368
|
self._enabled_providers.append(
|
375
|
-
|
369
|
+
VLLMProvider(
|
376
370
|
name="vllm",
|
377
371
|
base_url=model_settings.vllm_api_base,
|
372
|
+
default_prompt_formatter=model_settings.default_prompt_formatter,
|
378
373
|
)
|
379
374
|
)
|
375
|
+
|
380
376
|
if model_settings.aws_access_key_id and model_settings.aws_secret_access_key and model_settings.aws_default_region:
|
381
377
|
self._enabled_providers.append(
|
382
378
|
BedrockProvider(
|
letta/services/agent_manager.py
CHANGED
@@ -86,8 +86,10 @@ from letta.services.helpers.agent_manager_helper import (
|
|
86
86
|
calculate_multi_agent_tools,
|
87
87
|
check_supports_structured_output,
|
88
88
|
compile_system_message,
|
89
|
+
compile_system_message_async,
|
89
90
|
derive_system_message,
|
90
91
|
initialize_message_sequence,
|
92
|
+
initialize_message_sequence_async,
|
91
93
|
package_initial_message_sequence,
|
92
94
|
validate_agent_exists_async,
|
93
95
|
)
|
@@ -621,7 +623,7 @@ class AgentManager:
|
|
621
623
|
|
622
624
|
# initial message sequence (skip if _init_with_no_messages is True)
|
623
625
|
if not _init_with_no_messages:
|
624
|
-
init_messages = self.
|
626
|
+
init_messages = await self._generate_initial_message_sequence_async(
|
625
627
|
actor,
|
626
628
|
agent_state=result,
|
627
629
|
supplied_initial_message_sequence=agent_create.initial_message_sequence,
|
@@ -666,6 +668,35 @@ class AgentManager:
|
|
666
668
|
|
667
669
|
return init_messages
|
668
670
|
|
671
|
+
@enforce_types
|
672
|
+
async def _generate_initial_message_sequence_async(
|
673
|
+
self, actor: PydanticUser, agent_state: PydanticAgentState, supplied_initial_message_sequence: Optional[List[MessageCreate]] = None
|
674
|
+
) -> List[Message]:
|
675
|
+
init_messages = await initialize_message_sequence_async(
|
676
|
+
agent_state=agent_state, memory_edit_timestamp=get_utc_time(), include_initial_boot_message=True
|
677
|
+
)
|
678
|
+
if supplied_initial_message_sequence is not None:
|
679
|
+
# We always need the system prompt up front
|
680
|
+
system_message_obj = PydanticMessage.dict_to_message(
|
681
|
+
agent_id=agent_state.id,
|
682
|
+
model=agent_state.llm_config.model,
|
683
|
+
openai_message_dict=init_messages[0],
|
684
|
+
)
|
685
|
+
# Don't use anything else in the pregen sequence, instead use the provided sequence
|
686
|
+
init_messages = [system_message_obj]
|
687
|
+
init_messages.extend(
|
688
|
+
package_initial_message_sequence(
|
689
|
+
agent_state.id, supplied_initial_message_sequence, agent_state.llm_config.model, agent_state.timezone, actor
|
690
|
+
)
|
691
|
+
)
|
692
|
+
else:
|
693
|
+
init_messages = [
|
694
|
+
PydanticMessage.dict_to_message(agent_id=agent_state.id, model=agent_state.llm_config.model, openai_message_dict=msg)
|
695
|
+
for msg in init_messages
|
696
|
+
]
|
697
|
+
|
698
|
+
return init_messages
|
699
|
+
|
669
700
|
@enforce_types
|
670
701
|
@trace_method
|
671
702
|
def append_initial_message_sequence_to_in_context_messages(
|
@@ -679,7 +710,7 @@ class AgentManager:
|
|
679
710
|
async def append_initial_message_sequence_to_in_context_messages_async(
|
680
711
|
self, actor: PydanticUser, agent_state: PydanticAgentState, initial_message_sequence: Optional[List[MessageCreate]] = None
|
681
712
|
) -> PydanticAgentState:
|
682
|
-
init_messages = self.
|
713
|
+
init_messages = await self._generate_initial_message_sequence_async(actor, agent_state, initial_message_sequence)
|
683
714
|
return await self.append_to_in_context_messages_async(init_messages, agent_id=agent_state.id, actor=actor)
|
684
715
|
|
685
716
|
@enforce_types
|
@@ -1034,6 +1065,7 @@ class AgentManager:
|
|
1034
1065
|
include_relationships: Optional[List[str]] = None,
|
1035
1066
|
ascending: bool = True,
|
1036
1067
|
sort_by: Optional[str] = "created_at",
|
1068
|
+
show_hidden_agents: Optional[bool] = None,
|
1037
1069
|
) -> List[PydanticAgentState]:
|
1038
1070
|
"""
|
1039
1071
|
Retrieves agents with optimized filtering and optional field selection.
|
@@ -1055,6 +1087,7 @@ class AgentManager:
|
|
1055
1087
|
include_relationships (Optional[List[str]]): List of fields to load for performance optimization.
|
1056
1088
|
ascending (bool): Sort agents in ascending order.
|
1057
1089
|
sort_by (Optional[str]): Sort agents by this field.
|
1090
|
+
show_hidden_agents (bool): If True, include agents marked as hidden in the results.
|
1058
1091
|
|
1059
1092
|
Returns:
|
1060
1093
|
List[PydanticAgentState]: The filtered list of matching agents.
|
@@ -1068,6 +1101,10 @@ class AgentManager:
|
|
1068
1101
|
query = _apply_identity_filters(query, identity_id, identifier_keys)
|
1069
1102
|
query = _apply_tag_filter(query, tags, match_all_tags)
|
1070
1103
|
query = _apply_relationship_filters(query, include_relationships)
|
1104
|
+
|
1105
|
+
# Apply hidden filter
|
1106
|
+
if not show_hidden_agents:
|
1107
|
+
query = query.where((AgentModel.hidden.is_(None)) | (AgentModel.hidden == False))
|
1071
1108
|
query = await _apply_pagination_async(query, before, after, session, ascending=ascending, sort_by=sort_by)
|
1072
1109
|
|
1073
1110
|
if limit:
|
@@ -1668,7 +1705,7 @@ class AgentManager:
|
|
1668
1705
|
|
1669
1706
|
# update memory (TODO: potentially update recall/archival stats separately)
|
1670
1707
|
|
1671
|
-
new_system_message_str =
|
1708
|
+
new_system_message_str = await compile_system_message_async(
|
1672
1709
|
system_prompt=agent_state.system,
|
1673
1710
|
in_context_memory=agent_state.memory,
|
1674
1711
|
in_context_memory_last_edit=memory_edit_timestamp,
|
@@ -1803,7 +1840,7 @@ class AgentManager:
|
|
1803
1840
|
|
1804
1841
|
# Optionally add default initial messages after the system message
|
1805
1842
|
if add_default_initial_messages:
|
1806
|
-
init_messages =
|
1843
|
+
init_messages = await initialize_message_sequence_async(
|
1807
1844
|
agent_state=agent_state, memory_edit_timestamp=get_utc_time(), include_initial_boot_message=True
|
1808
1845
|
)
|
1809
1846
|
# Skip index 0 (system message) since we preserved the original
|
letta/services/block_manager.py
CHANGED
@@ -2,7 +2,7 @@ import asyncio
|
|
2
2
|
from datetime import datetime
|
3
3
|
from typing import Dict, List, Optional
|
4
4
|
|
5
|
-
from sqlalchemy import delete, or_, select
|
5
|
+
from sqlalchemy import and_, delete, func, or_, select
|
6
6
|
from sqlalchemy.orm import Session
|
7
7
|
|
8
8
|
from letta.log import get_logger
|
@@ -182,6 +182,12 @@ class BlockManager:
|
|
182
182
|
before: Optional[str] = None,
|
183
183
|
after: Optional[str] = None,
|
184
184
|
limit: Optional[int] = 50,
|
185
|
+
label_search: Optional[str] = None,
|
186
|
+
description_search: Optional[str] = None,
|
187
|
+
value_search: Optional[str] = None,
|
188
|
+
connected_to_agents_count_gt: Optional[int] = None,
|
189
|
+
connected_to_agents_count_lt: Optional[int] = None,
|
190
|
+
connected_to_agents_count_eq: Optional[List[int]] = None,
|
185
191
|
ascending: bool = True,
|
186
192
|
) -> List[PydanticBlock]:
|
187
193
|
"""Async version of get_blocks method. Retrieve blocks based on various optional filters."""
|
@@ -214,8 +220,64 @@ class BlockManager:
|
|
214
220
|
if project_id:
|
215
221
|
query = query.where(BlockModel.project_id == project_id)
|
216
222
|
|
223
|
+
if label_search and not label:
|
224
|
+
query = query.where(BlockModel.label.ilike(f"%{label_search}%"))
|
225
|
+
|
226
|
+
if description_search:
|
227
|
+
query = query.where(BlockModel.description.ilike(f"%{description_search}%"))
|
228
|
+
|
229
|
+
if value_search:
|
230
|
+
query = query.where(BlockModel.value.ilike(f"%{value_search}%"))
|
231
|
+
|
217
232
|
needs_distinct = False
|
218
233
|
|
234
|
+
needs_agent_count_join = any(
|
235
|
+
condition is not None
|
236
|
+
for condition in [connected_to_agents_count_gt, connected_to_agents_count_lt, connected_to_agents_count_eq]
|
237
|
+
)
|
238
|
+
|
239
|
+
# If any agent count filters are specified, create a single subquery and apply all filters
|
240
|
+
if needs_agent_count_join:
|
241
|
+
# Create a subquery to count agents per block
|
242
|
+
agent_count_subquery = (
|
243
|
+
select(BlocksAgents.block_id, func.count(BlocksAgents.agent_id).label("agent_count"))
|
244
|
+
.group_by(BlocksAgents.block_id)
|
245
|
+
.subquery()
|
246
|
+
)
|
247
|
+
|
248
|
+
# Determine if we need a left join (for cases involving 0 counts)
|
249
|
+
needs_left_join = (connected_to_agents_count_lt is not None) or (
|
250
|
+
connected_to_agents_count_eq is not None and 0 in connected_to_agents_count_eq
|
251
|
+
)
|
252
|
+
|
253
|
+
if needs_left_join:
|
254
|
+
# Left join to include blocks with no agents
|
255
|
+
query = query.outerjoin(agent_count_subquery, BlockModel.id == agent_count_subquery.c.block_id)
|
256
|
+
# Use coalesce to treat NULL as 0 for blocks with no agents
|
257
|
+
agent_count_expr = func.coalesce(agent_count_subquery.c.agent_count, 0)
|
258
|
+
else:
|
259
|
+
# Inner join since we don't need blocks with no agents
|
260
|
+
query = query.join(agent_count_subquery, BlockModel.id == agent_count_subquery.c.block_id)
|
261
|
+
agent_count_expr = agent_count_subquery.c.agent_count
|
262
|
+
|
263
|
+
# Build the combined filter conditions
|
264
|
+
conditions = []
|
265
|
+
|
266
|
+
if connected_to_agents_count_gt is not None:
|
267
|
+
conditions.append(agent_count_expr > connected_to_agents_count_gt)
|
268
|
+
|
269
|
+
if connected_to_agents_count_lt is not None:
|
270
|
+
conditions.append(agent_count_expr < connected_to_agents_count_lt)
|
271
|
+
|
272
|
+
if connected_to_agents_count_eq is not None:
|
273
|
+
conditions.append(agent_count_expr.in_(connected_to_agents_count_eq))
|
274
|
+
|
275
|
+
# Apply all conditions with AND logic
|
276
|
+
if conditions:
|
277
|
+
query = query.where(and_(*conditions))
|
278
|
+
|
279
|
+
needs_distinct = True
|
280
|
+
|
219
281
|
if identifier_keys:
|
220
282
|
query = query.join(BlockModel.identities).filter(
|
221
283
|
BlockModel.identities.property.mapper.class_.identifier_key.in_(identifier_keys)
|
@@ -130,37 +130,38 @@ class LineChunker:
|
|
130
130
|
# Apply the appropriate chunking strategy
|
131
131
|
if strategy == ChunkingStrategy.DOCUMENTATION:
|
132
132
|
content_lines = self._chunk_by_sentences(text)
|
133
|
-
elif strategy == ChunkingStrategy.PROSE:
|
134
|
-
content_lines = self._chunk_by_characters(text)
|
135
133
|
elif strategy == ChunkingStrategy.CODE:
|
136
134
|
content_lines = self._chunk_by_lines(text, preserve_indentation=True)
|
137
135
|
else: # STRUCTURED_DATA or LINE_BASED
|
138
136
|
content_lines = self._chunk_by_lines(text, preserve_indentation=False)
|
139
137
|
|
140
138
|
total_chunks = len(content_lines)
|
141
|
-
chunk_type =
|
142
|
-
"sentences" if strategy == ChunkingStrategy.DOCUMENTATION else "chunks" if strategy == ChunkingStrategy.PROSE else "lines"
|
143
|
-
)
|
139
|
+
chunk_type = "sentences" if strategy == ChunkingStrategy.DOCUMENTATION else "lines"
|
144
140
|
|
145
|
-
#
|
146
|
-
if
|
141
|
+
# Handle range validation and clamping
|
142
|
+
if start is not None or end is not None:
|
143
|
+
# Always validate that start < end if both are specified
|
144
|
+
if start is not None and end is not None and start >= end:
|
145
|
+
if validate_range:
|
146
|
+
raise ValueError(f"Invalid range: start ({start}) must be less than end ({end})")
|
147
|
+
# If validation is off, we still need to handle this case sensibly
|
148
|
+
# but we'll allow it to proceed with an empty result
|
149
|
+
|
150
|
+
# Always check that start is within bounds - this should error regardless of validation flag
|
147
151
|
if start is not None and start >= total_chunks:
|
148
|
-
# Convert to 1-indexed for user-friendly error message
|
149
|
-
start_display = start + 1
|
150
152
|
raise ValueError(
|
151
|
-
f"File {file_metadata.file_name} has only {total_chunks}
|
153
|
+
f"File {file_metadata.file_name} has only {total_chunks} {chunk_type}, but requested offset {start + 1} is out of range"
|
152
154
|
)
|
153
155
|
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
end_display = end
|
158
|
-
raise ValueError(
|
159
|
-
f"File {file_metadata.file_name} has only {total_chunks} lines, but requested range {start_display} to {end_display} extends beyond file bounds"
|
160
|
-
)
|
156
|
+
# Apply bounds checking
|
157
|
+
if start is not None:
|
158
|
+
start = max(0, start) # Ensure non-negative
|
161
159
|
|
162
|
-
|
163
|
-
|
160
|
+
# Only clamp end if it exceeds the file length
|
161
|
+
if end is not None:
|
162
|
+
end = min(end, total_chunks)
|
163
|
+
|
164
|
+
# Apply slicing
|
164
165
|
content_lines = content_lines[start:end]
|
165
166
|
line_offset = start if start is not None else 0
|
166
167
|
else:
|
@@ -12,7 +12,6 @@ from letta.schemas.passage import Passage
|
|
12
12
|
from letta.schemas.user import User
|
13
13
|
from letta.services.agent_manager import AgentManager
|
14
14
|
from letta.services.file_manager import FileManager
|
15
|
-
from letta.services.file_processor.chunker.line_chunker import LineChunker
|
16
15
|
from letta.services.file_processor.chunker.llama_index_chunker import LlamaIndexChunker
|
17
16
|
from letta.services.file_processor.embedder.base_embedder import BaseEmbedder
|
18
17
|
from letta.services.file_processor.parser.base_parser import FileParser
|
@@ -35,7 +34,6 @@ class FileProcessor:
|
|
35
34
|
max_file_size: int = 50 * 1024 * 1024, # 50MB default
|
36
35
|
):
|
37
36
|
self.file_parser = file_parser
|
38
|
-
self.line_chunker = LineChunker()
|
39
37
|
self.embedder = embedder
|
40
38
|
self.max_file_size = max_file_size
|
41
39
|
self.file_manager = FileManager()
|
@@ -17,7 +17,6 @@ class ChunkingStrategy(str, Enum):
|
|
17
17
|
CODE = "code" # Line-based chunking for code files
|
18
18
|
STRUCTURED_DATA = "structured_data" # Line-based chunking for JSON, XML, etc.
|
19
19
|
DOCUMENTATION = "documentation" # Paragraph-aware chunking for Markdown, HTML
|
20
|
-
PROSE = "prose" # Character-based wrapping for plain text
|
21
20
|
LINE_BASED = "line_based" # Default line-based chunking
|
22
21
|
|
23
22
|
|
@@ -44,7 +43,7 @@ class FileTypeRegistry:
|
|
44
43
|
"""Register all default supported file types."""
|
45
44
|
# Document formats
|
46
45
|
self.register(".pdf", "application/pdf", False, "PDF document", ChunkingStrategy.LINE_BASED)
|
47
|
-
self.register(".txt", "text/plain", True, "Plain text file", ChunkingStrategy.
|
46
|
+
self.register(".txt", "text/plain", True, "Plain text file", ChunkingStrategy.LINE_BASED)
|
48
47
|
self.register(".md", "text/markdown", True, "Markdown document", ChunkingStrategy.DOCUMENTATION)
|
49
48
|
self.register(".markdown", "text/markdown", True, "Markdown document", ChunkingStrategy.DOCUMENTATION)
|
50
49
|
self.register(".json", "application/json", True, "JSON data file", ChunkingStrategy.STRUCTURED_DATA)
|
@@ -1,5 +1,5 @@
|
|
1
1
|
from datetime import datetime, timezone
|
2
|
-
from typing import List, Optional, Union
|
2
|
+
from typing import Dict, List, Optional, Union
|
3
3
|
|
4
4
|
from sqlalchemy import and_, delete, func, or_, select, update
|
5
5
|
|
@@ -34,6 +34,8 @@ class FileAgentManager:
|
|
34
34
|
max_files_open: int,
|
35
35
|
is_open: bool = True,
|
36
36
|
visible_content: Optional[str] = None,
|
37
|
+
start_line: Optional[int] = None,
|
38
|
+
end_line: Optional[int] = None,
|
37
39
|
) -> tuple[PydanticFileAgent, List[str]]:
|
38
40
|
"""
|
39
41
|
Idempotently attach *file_id* to *agent_id* with LRU enforcement.
|
@@ -48,7 +50,7 @@ class FileAgentManager:
|
|
48
50
|
"""
|
49
51
|
if is_open:
|
50
52
|
# Use the efficient LRU + open method
|
51
|
-
closed_files, was_already_open = await self.enforce_max_open_files_and_open(
|
53
|
+
closed_files, was_already_open, _ = await self.enforce_max_open_files_and_open(
|
52
54
|
agent_id=agent_id,
|
53
55
|
file_id=file_id,
|
54
56
|
file_name=file_name,
|
@@ -56,6 +58,8 @@ class FileAgentManager:
|
|
56
58
|
actor=actor,
|
57
59
|
visible_content=visible_content or "",
|
58
60
|
max_files_open=max_files_open,
|
61
|
+
start_line=start_line,
|
62
|
+
end_line=end_line,
|
59
63
|
)
|
60
64
|
|
61
65
|
# Get the updated file agent to return
|
@@ -85,6 +89,8 @@ class FileAgentManager:
|
|
85
89
|
existing.visible_content = visible_content
|
86
90
|
|
87
91
|
existing.last_accessed_at = now_ts
|
92
|
+
existing.start_line = start_line
|
93
|
+
existing.end_line = end_line
|
88
94
|
|
89
95
|
await existing.update_async(session, actor=actor)
|
90
96
|
return existing.to_pydantic(), []
|
@@ -98,6 +104,8 @@ class FileAgentManager:
|
|
98
104
|
is_open=is_open,
|
99
105
|
visible_content=visible_content,
|
100
106
|
last_accessed_at=now_ts,
|
107
|
+
start_line=start_line,
|
108
|
+
end_line=end_line,
|
101
109
|
)
|
102
110
|
await assoc.create_async(session, actor=actor)
|
103
111
|
return assoc.to_pydantic(), []
|
@@ -112,6 +120,8 @@ class FileAgentManager:
|
|
112
120
|
actor: PydanticUser,
|
113
121
|
is_open: Optional[bool] = None,
|
114
122
|
visible_content: Optional[str] = None,
|
123
|
+
start_line: Optional[int] = None,
|
124
|
+
end_line: Optional[int] = None,
|
115
125
|
) -> PydanticFileAgent:
|
116
126
|
"""Patch an existing association row."""
|
117
127
|
async with db_registry.async_session() as session:
|
@@ -121,6 +131,10 @@ class FileAgentManager:
|
|
121
131
|
assoc.is_open = is_open
|
122
132
|
if visible_content is not None:
|
123
133
|
assoc.visible_content = visible_content
|
134
|
+
if start_line is not None:
|
135
|
+
assoc.start_line = start_line
|
136
|
+
if end_line is not None:
|
137
|
+
assoc.end_line = end_line
|
124
138
|
|
125
139
|
# touch timestamp
|
126
140
|
assoc.last_accessed_at = datetime.now(timezone.utc)
|
@@ -373,8 +387,18 @@ class FileAgentManager:
|
|
373
387
|
@enforce_types
|
374
388
|
@trace_method
|
375
389
|
async def enforce_max_open_files_and_open(
|
376
|
-
self,
|
377
|
-
|
390
|
+
self,
|
391
|
+
*,
|
392
|
+
agent_id: str,
|
393
|
+
file_id: str,
|
394
|
+
file_name: str,
|
395
|
+
source_id: str,
|
396
|
+
actor: PydanticUser,
|
397
|
+
visible_content: str,
|
398
|
+
max_files_open: int,
|
399
|
+
start_line: Optional[int] = None,
|
400
|
+
end_line: Optional[int] = None,
|
401
|
+
) -> tuple[List[str], bool, Dict[str, tuple[Optional[int], Optional[int]]]]:
|
378
402
|
"""
|
379
403
|
Efficiently handle LRU eviction and file opening in a single transaction.
|
380
404
|
|
@@ -387,7 +411,8 @@ class FileAgentManager:
|
|
387
411
|
visible_content: Content to set for the opened file
|
388
412
|
|
389
413
|
Returns:
|
390
|
-
Tuple of (closed_file_names, file_was_already_open)
|
414
|
+
Tuple of (closed_file_names, file_was_already_open, previous_ranges)
|
415
|
+
where previous_ranges maps file names to their old (start_line, end_line) ranges
|
391
416
|
"""
|
392
417
|
async with db_registry.async_session() as session:
|
393
418
|
# Single query to get ALL open files for this agent, ordered by last_accessed_at (oldest first)
|
@@ -423,6 +448,17 @@ class FileAgentManager:
|
|
423
448
|
|
424
449
|
file_was_already_open = file_to_open is not None and file_to_open.is_open
|
425
450
|
|
451
|
+
# Capture previous line range if file was already open and we're changing the range
|
452
|
+
previous_ranges = {}
|
453
|
+
if file_was_already_open and file_to_open:
|
454
|
+
old_start = file_to_open.start_line
|
455
|
+
old_end = file_to_open.end_line
|
456
|
+
# Only record if there was a previous range or if we're setting a new range
|
457
|
+
if old_start is not None or old_end is not None or start_line is not None or end_line is not None:
|
458
|
+
# Only record if the range is actually changing
|
459
|
+
if old_start != start_line or old_end != end_line:
|
460
|
+
previous_ranges[file_name] = (old_start, old_end)
|
461
|
+
|
426
462
|
# Calculate how many files need to be closed
|
427
463
|
current_other_count = len(other_open_files)
|
428
464
|
target_other_count = max_files_open - 1 # Reserve 1 slot for file we're opening
|
@@ -458,6 +494,8 @@ class FileAgentManager:
|
|
458
494
|
file_to_open.is_open = True
|
459
495
|
file_to_open.visible_content = visible_content
|
460
496
|
file_to_open.last_accessed_at = now_ts
|
497
|
+
file_to_open.start_line = start_line
|
498
|
+
file_to_open.end_line = end_line
|
461
499
|
await file_to_open.update_async(session, actor=actor)
|
462
500
|
else:
|
463
501
|
# Create new file association
|
@@ -470,10 +508,12 @@ class FileAgentManager:
|
|
470
508
|
is_open=True,
|
471
509
|
visible_content=visible_content,
|
472
510
|
last_accessed_at=now_ts,
|
511
|
+
start_line=start_line,
|
512
|
+
end_line=end_line,
|
473
513
|
)
|
474
514
|
await new_file_agent.create_async(session, actor=actor)
|
475
515
|
|
476
|
-
return closed_file_names, file_was_already_open
|
516
|
+
return closed_file_names, file_was_already_open, previous_ranges
|
477
517
|
|
478
518
|
@enforce_types
|
479
519
|
@trace_method
|