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.
Files changed (77) hide show
  1. letta/__init__.py +2 -1
  2. letta/agent.py +1 -1
  3. letta/agents/base_agent.py +2 -2
  4. letta/agents/letta_agent.py +22 -8
  5. letta/agents/letta_agent_batch.py +2 -2
  6. letta/agents/voice_agent.py +2 -2
  7. letta/client/client.py +0 -11
  8. letta/data_sources/redis_client.py +1 -2
  9. letta/errors.py +11 -0
  10. letta/functions/function_sets/builtin.py +3 -7
  11. letta/functions/mcp_client/types.py +107 -1
  12. letta/helpers/reasoning_helper.py +48 -0
  13. letta/helpers/tool_execution_helper.py +2 -65
  14. letta/interfaces/openai_streaming_interface.py +38 -2
  15. letta/llm_api/anthropic_client.py +1 -5
  16. letta/llm_api/google_vertex_client.py +1 -1
  17. letta/llm_api/llm_client.py +1 -1
  18. letta/llm_api/openai_client.py +2 -0
  19. letta/llm_api/sample_response_jsons/lmstudio_embedding_list.json +3 -2
  20. letta/orm/agent.py +5 -0
  21. letta/orm/enums.py +0 -1
  22. letta/orm/file.py +0 -1
  23. letta/orm/files_agents.py +9 -9
  24. letta/orm/sandbox_config.py +1 -1
  25. letta/orm/sqlite_functions.py +15 -13
  26. letta/prompts/system/memgpt_generate_tool.txt +139 -0
  27. letta/schemas/agent.py +15 -1
  28. letta/schemas/enums.py +6 -0
  29. letta/schemas/file.py +3 -3
  30. letta/schemas/letta_ping.py +28 -0
  31. letta/schemas/letta_request.py +9 -0
  32. letta/schemas/letta_stop_reason.py +25 -0
  33. letta/schemas/llm_config.py +1 -0
  34. letta/schemas/mcp.py +16 -3
  35. letta/schemas/memory.py +5 -0
  36. letta/schemas/providers/lmstudio.py +7 -0
  37. letta/schemas/providers/ollama.py +11 -8
  38. letta/schemas/sandbox_config.py +17 -7
  39. letta/server/rest_api/app.py +2 -0
  40. letta/server/rest_api/routers/v1/agents.py +93 -30
  41. letta/server/rest_api/routers/v1/blocks.py +52 -0
  42. letta/server/rest_api/routers/v1/sandbox_configs.py +2 -1
  43. letta/server/rest_api/routers/v1/tools.py +43 -101
  44. letta/server/rest_api/streaming_response.py +121 -9
  45. letta/server/server.py +6 -10
  46. letta/services/agent_manager.py +41 -4
  47. letta/services/block_manager.py +63 -1
  48. letta/services/file_processor/chunker/line_chunker.py +20 -19
  49. letta/services/file_processor/file_processor.py +0 -2
  50. letta/services/file_processor/file_types.py +1 -2
  51. letta/services/files_agents_manager.py +46 -6
  52. letta/services/helpers/agent_manager_helper.py +185 -13
  53. letta/services/job_manager.py +4 -4
  54. letta/services/mcp/oauth_utils.py +6 -150
  55. letta/services/mcp_manager.py +120 -2
  56. letta/services/sandbox_config_manager.py +3 -5
  57. letta/services/tool_executor/builtin_tool_executor.py +13 -18
  58. letta/services/tool_executor/files_tool_executor.py +31 -27
  59. letta/services/tool_executor/mcp_tool_executor.py +10 -1
  60. letta/services/tool_executor/{tool_executor.py → sandbox_tool_executor.py} +14 -2
  61. letta/services/tool_executor/tool_execution_manager.py +1 -1
  62. letta/services/tool_executor/tool_execution_sandbox.py +2 -1
  63. letta/services/tool_manager.py +59 -21
  64. letta/services/tool_sandbox/base.py +18 -2
  65. letta/services/tool_sandbox/e2b_sandbox.py +5 -35
  66. letta/services/tool_sandbox/local_sandbox.py +5 -22
  67. letta/services/tool_sandbox/modal_sandbox.py +205 -0
  68. letta/settings.py +27 -8
  69. letta/system.py +1 -4
  70. letta/templates/template_helper.py +5 -0
  71. letta/utils.py +14 -2
  72. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/METADATA +7 -3
  73. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/RECORD +76 -73
  74. letta/orm/__all__.py +0 -15
  75. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/LICENSE +0 -0
  76. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/WHEEL +0 -0
  77. {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 CancelledError to interrupt the stream
63
- raise asyncio.CancelledError(f"Job {job_id} was cancelled")
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
- # This should be handled properly upstream?
144
- except asyncio.CancelledError as exc:
145
- logger.warning("Stream was cancelled by client or job cancellation")
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 = {"error": {"message": "Stream cancelled"}}
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, SandboxType
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
- VLLMChatCompletionsProvider(
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(
@@ -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._generate_initial_message_sequence(
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._generate_initial_message_sequence(actor, agent_state, initial_message_sequence)
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 = compile_system_message(
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 = initialize_message_sequence(
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
@@ -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
- # Validate range if requested
146
- if validate_range and (start is not None or end is not None):
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} lines, but requested offset {start_display} is out of range"
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
- if start is not None and end is not None and end > total_chunks:
155
- # Convert to 1-indexed for user-friendly error message
156
- start_display = start + 1
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
- # Handle start/end slicing
163
- if start is not None or end is not None:
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.PROSE)
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, *, agent_id: str, file_id: str, file_name: str, source_id: str, actor: PydanticUser, visible_content: str, max_files_open: int
377
- ) -> tuple[List[str], bool]:
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