letta-nightly 0.8.0.dev20250606195656__py3-none-any.whl → 0.8.2.dev20250606215616__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 (96) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +1 -1
  3. letta/agents/letta_agent.py +49 -29
  4. letta/agents/letta_agent_batch.py +1 -2
  5. letta/agents/voice_agent.py +19 -13
  6. letta/agents/voice_sleeptime_agent.py +11 -3
  7. letta/constants.py +18 -0
  8. letta/data_sources/__init__.py +0 -0
  9. letta/data_sources/redis_client.py +282 -0
  10. letta/errors.py +0 -4
  11. letta/functions/function_sets/files.py +58 -0
  12. letta/functions/schema_generator.py +18 -1
  13. letta/groups/sleeptime_multi_agent_v2.py +1 -1
  14. letta/helpers/datetime_helpers.py +47 -3
  15. letta/helpers/decorators.py +69 -0
  16. letta/{services/helpers/noop_helper.py → helpers/singleton.py} +5 -0
  17. letta/interfaces/anthropic_streaming_interface.py +43 -24
  18. letta/interfaces/openai_streaming_interface.py +21 -19
  19. letta/llm_api/anthropic.py +1 -1
  20. letta/llm_api/anthropic_client.py +22 -14
  21. letta/llm_api/google_vertex_client.py +1 -1
  22. letta/llm_api/helpers.py +36 -30
  23. letta/llm_api/llm_api_tools.py +1 -1
  24. letta/llm_api/llm_client_base.py +29 -1
  25. letta/llm_api/openai.py +1 -1
  26. letta/llm_api/openai_client.py +6 -8
  27. letta/local_llm/chat_completion_proxy.py +1 -1
  28. letta/memory.py +1 -1
  29. letta/orm/enums.py +1 -0
  30. letta/orm/file.py +80 -3
  31. letta/orm/files_agents.py +13 -0
  32. letta/orm/sqlalchemy_base.py +34 -11
  33. letta/otel/__init__.py +0 -0
  34. letta/otel/context.py +25 -0
  35. letta/otel/events.py +0 -0
  36. letta/otel/metric_registry.py +122 -0
  37. letta/otel/metrics.py +66 -0
  38. letta/otel/resource.py +26 -0
  39. letta/{tracing.py → otel/tracing.py} +55 -78
  40. letta/plugins/README.md +22 -0
  41. letta/plugins/__init__.py +0 -0
  42. letta/plugins/defaults.py +11 -0
  43. letta/plugins/plugins.py +72 -0
  44. letta/schemas/enums.py +8 -0
  45. letta/schemas/file.py +12 -0
  46. letta/schemas/tool.py +4 -0
  47. letta/server/db.py +7 -7
  48. letta/server/rest_api/app.py +8 -6
  49. letta/server/rest_api/routers/v1/agents.py +37 -36
  50. letta/server/rest_api/routers/v1/groups.py +3 -3
  51. letta/server/rest_api/routers/v1/sources.py +26 -3
  52. letta/server/rest_api/utils.py +9 -6
  53. letta/server/server.py +18 -12
  54. letta/services/agent_manager.py +185 -193
  55. letta/services/block_manager.py +1 -1
  56. letta/services/context_window_calculator/token_counter.py +3 -2
  57. letta/services/file_processor/chunker/line_chunker.py +34 -0
  58. letta/services/file_processor/file_processor.py +40 -11
  59. letta/services/file_processor/parser/mistral_parser.py +11 -1
  60. letta/services/files_agents_manager.py +96 -7
  61. letta/services/group_manager.py +6 -6
  62. letta/services/helpers/agent_manager_helper.py +373 -3
  63. letta/services/identity_manager.py +1 -1
  64. letta/services/job_manager.py +1 -1
  65. letta/services/llm_batch_manager.py +1 -1
  66. letta/services/message_manager.py +1 -1
  67. letta/services/organization_manager.py +1 -1
  68. letta/services/passage_manager.py +1 -1
  69. letta/services/per_agent_lock_manager.py +1 -1
  70. letta/services/provider_manager.py +1 -1
  71. letta/services/sandbox_config_manager.py +1 -1
  72. letta/services/source_manager.py +178 -19
  73. letta/services/step_manager.py +2 -2
  74. letta/services/summarizer/summarizer.py +1 -1
  75. letta/services/telemetry_manager.py +1 -1
  76. letta/services/tool_executor/builtin_tool_executor.py +117 -0
  77. letta/services/tool_executor/composio_tool_executor.py +53 -0
  78. letta/services/tool_executor/core_tool_executor.py +474 -0
  79. letta/services/tool_executor/files_tool_executor.py +131 -0
  80. letta/services/tool_executor/mcp_tool_executor.py +45 -0
  81. letta/services/tool_executor/multi_agent_tool_executor.py +123 -0
  82. letta/services/tool_executor/tool_execution_manager.py +34 -14
  83. letta/services/tool_executor/tool_execution_sandbox.py +1 -1
  84. letta/services/tool_executor/tool_executor.py +3 -802
  85. letta/services/tool_executor/tool_executor_base.py +43 -0
  86. letta/services/tool_manager.py +55 -59
  87. letta/services/tool_sandbox/e2b_sandbox.py +1 -1
  88. letta/services/tool_sandbox/local_sandbox.py +6 -3
  89. letta/services/user_manager.py +6 -3
  90. letta/settings.py +21 -1
  91. letta/utils.py +7 -2
  92. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.2.dev20250606215616.dist-info}/METADATA +4 -2
  93. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.2.dev20250606215616.dist-info}/RECORD +96 -74
  94. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.2.dev20250606215616.dist-info}/LICENSE +0 -0
  95. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.2.dev20250606215616.dist-info}/WHEEL +0 -0
  96. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.2.dev20250606215616.dist-info}/entry_points.txt +0 -0
@@ -3,9 +3,8 @@ import os
3
3
  from datetime import datetime, timezone
4
4
  from typing import Dict, List, Optional, Set, Tuple
5
5
 
6
- import numpy as np
7
6
  import sqlalchemy as sa
8
- from sqlalchemy import Select, and_, delete, func, insert, literal, or_, select, union_all
7
+ from sqlalchemy import delete, func, insert, literal, or_, select
9
8
  from sqlalchemy.dialects.postgresql import insert as pg_insert
10
9
 
11
10
  from letta.constants import (
@@ -17,10 +16,9 @@ from letta.constants import (
17
16
  BASE_VOICE_SLEEPTIME_CHAT_TOOLS,
18
17
  BASE_VOICE_SLEEPTIME_TOOLS,
19
18
  DATA_SOURCE_ATTACH_ALERT,
20
- MAX_EMBEDDING_DIM,
19
+ FILES_TOOLS,
21
20
  MULTI_AGENT_TOOLS,
22
21
  )
23
- from letta.embeddings import embedding_model
24
22
  from letta.helpers.datetime_helpers import get_utc_time
25
23
  from letta.llm_api.llm_client import LLMClient
26
24
  from letta.log import get_logger
@@ -39,7 +37,7 @@ from letta.orm.errors import NoResultFound
39
37
  from letta.orm.sandbox_config import AgentEnvironmentVariable
40
38
  from letta.orm.sandbox_config import AgentEnvironmentVariable as AgentEnvironmentVariableModel
41
39
  from letta.orm.sqlalchemy_base import AccessType
42
- from letta.orm.sqlite_functions import adapt_array
40
+ from letta.otel.tracing import trace_method
43
41
  from letta.schemas.agent import AgentState as PydanticAgentState
44
42
  from letta.schemas.agent import AgentType, CreateAgent, UpdateAgent, get_prompt_template_for_agent_type
45
43
  from letta.schemas.block import DEFAULT_BLOCKS
@@ -66,6 +64,7 @@ from letta.server.db import db_registry
66
64
  from letta.services.block_manager import BlockManager
67
65
  from letta.services.context_window_calculator.context_window_calculator import ContextWindowCalculator
68
66
  from letta.services.context_window_calculator.token_counter import AnthropicTokenCounter, TiktokenCounter
67
+ from letta.services.files_agents_manager import FileAgentManager
69
68
  from letta.services.helpers.agent_manager_helper import (
70
69
  _apply_filters,
71
70
  _apply_identity_filters,
@@ -74,6 +73,9 @@ from letta.services.helpers.agent_manager_helper import (
74
73
  _apply_tag_filter,
75
74
  _process_relationship,
76
75
  _process_relationship_async,
76
+ build_agent_passage_query,
77
+ build_passage_query,
78
+ build_source_passage_query,
77
79
  check_supports_structured_output,
78
80
  compile_system_message,
79
81
  derive_system_message,
@@ -85,8 +87,6 @@ from letta.services.message_manager import MessageManager
85
87
  from letta.services.passage_manager import PassageManager
86
88
  from letta.services.source_manager import SourceManager
87
89
  from letta.services.tool_manager import ToolManager
88
- from letta.settings import settings
89
- from letta.tracing import trace_method
90
90
  from letta.utils import enforce_types, united_diff
91
91
 
92
92
  logger = get_logger(__name__)
@@ -102,6 +102,7 @@ class AgentManager:
102
102
  self.message_manager = MessageManager()
103
103
  self.passage_manager = PassageManager()
104
104
  self.identity_manager = IdentityManager()
105
+ self.file_agent_manager = FileAgentManager()
105
106
 
106
107
  @staticmethod
107
108
  def _resolve_tools(session, names: Set[str], ids: Set[str], org_id: str) -> Tuple[Dict[str, str], Dict[str, str]]:
@@ -1384,6 +1385,11 @@ class AgentManager:
1384
1385
  curr_system_message = self.get_system_message(
1385
1386
  agent_id=agent_id, actor=actor
1386
1387
  ) # this is the system + memory bank, not just the system prompt
1388
+
1389
+ if curr_system_message is None:
1390
+ logger.warning(f"No system message found for agent {agent_state.id} and user {actor}")
1391
+ return agent_state
1392
+
1387
1393
  curr_system_message_openai = curr_system_message.to_openai_dict()
1388
1394
 
1389
1395
  # note: we only update the system prompt if the core memory is changed
@@ -1451,6 +1457,11 @@ class AgentManager:
1451
1457
  curr_system_message = await self.get_system_message_async(
1452
1458
  agent_id=agent_id, actor=actor
1453
1459
  ) # this is the system + memory bank, not just the system prompt
1460
+
1461
+ if curr_system_message is None:
1462
+ logger.warning(f"No system message found for agent {agent_state.id} and user {actor}")
1463
+ return agent_state
1464
+
1454
1465
  curr_system_message_openai = curr_system_message.to_openai_dict()
1455
1466
 
1456
1467
  # note: we only update the system prompt if the core memory is changed
@@ -1650,12 +1661,18 @@ class AgentManager:
1650
1661
  @trace_method
1651
1662
  @enforce_types
1652
1663
  async def refresh_memory_async(self, agent_state: PydanticAgentState, actor: PydanticUser) -> PydanticAgentState:
1664
+ # TODO: This will NOT work for new blocks/file blocks added intra-step
1653
1665
  block_ids = [b.id for b in agent_state.memory.blocks]
1654
- if not block_ids:
1655
- return agent_state
1666
+ file_block_names = [b.label for b in agent_state.memory.file_blocks]
1667
+
1668
+ if block_ids:
1669
+ blocks = await self.block_manager.get_all_blocks_by_ids_async(block_ids=[b.id for b in agent_state.memory.blocks], actor=actor)
1670
+ agent_state.memory.blocks = [b for b in blocks if b is not None]
1671
+
1672
+ if file_block_names:
1673
+ file_blocks = await self.file_agent_manager.get_all_file_blocks_by_name(file_names=file_block_names, actor=actor)
1674
+ agent_state.memory.file_blocks = [b for b in file_blocks if b is not None]
1656
1675
 
1657
- blocks = await self.block_manager.get_all_blocks_by_ids_async(block_ids=[b.id for b in agent_state.memory.blocks], actor=actor)
1658
- agent_state.memory.blocks = [b for b in blocks if b is not None]
1659
1676
  return agent_state
1660
1677
 
1661
1678
  # ======================================================================================================================
@@ -2006,184 +2023,6 @@ class AgentManager:
2006
2023
  # ======================================================================================================================
2007
2024
  # Passage Management
2008
2025
  # ======================================================================================================================
2009
- def _build_passage_query(
2010
- self,
2011
- actor: PydanticUser,
2012
- agent_id: Optional[str] = None,
2013
- file_id: Optional[str] = None,
2014
- query_text: Optional[str] = None,
2015
- start_date: Optional[datetime] = None,
2016
- end_date: Optional[datetime] = None,
2017
- before: Optional[str] = None,
2018
- after: Optional[str] = None,
2019
- source_id: Optional[str] = None,
2020
- embed_query: bool = False,
2021
- ascending: bool = True,
2022
- embedding_config: Optional[EmbeddingConfig] = None,
2023
- agent_only: bool = False,
2024
- ) -> Select:
2025
- """Helper function to build the base passage query with all filters applied.
2026
- Supports both before and after pagination across merged source and agent passages.
2027
-
2028
- Returns the query before any limit or count operations are applied.
2029
- """
2030
- embedded_text = None
2031
- if embed_query:
2032
- assert embedding_config is not None, "embedding_config must be specified for vector search"
2033
- assert query_text is not None, "query_text must be specified for vector search"
2034
- embedded_text = embedding_model(embedding_config).get_text_embedding(query_text)
2035
- embedded_text = np.array(embedded_text)
2036
- embedded_text = np.pad(embedded_text, (0, MAX_EMBEDDING_DIM - embedded_text.shape[0]), mode="constant").tolist()
2037
-
2038
- # Start with base query for source passages
2039
- source_passages = None
2040
- if not agent_only: # Include source passages
2041
- if agent_id is not None:
2042
- source_passages = (
2043
- select(SourcePassage, literal(None).label("agent_id"))
2044
- .join(SourcesAgents, SourcesAgents.source_id == SourcePassage.source_id)
2045
- .where(SourcesAgents.agent_id == agent_id)
2046
- .where(SourcePassage.organization_id == actor.organization_id)
2047
- )
2048
- else:
2049
- source_passages = select(SourcePassage, literal(None).label("agent_id")).where(
2050
- SourcePassage.organization_id == actor.organization_id
2051
- )
2052
-
2053
- if source_id:
2054
- source_passages = source_passages.where(SourcePassage.source_id == source_id)
2055
- if file_id:
2056
- source_passages = source_passages.where(SourcePassage.file_id == file_id)
2057
-
2058
- # Add agent passages query
2059
- agent_passages = None
2060
- if agent_id is not None:
2061
- agent_passages = (
2062
- select(
2063
- AgentPassage.id,
2064
- AgentPassage.text,
2065
- AgentPassage.embedding_config,
2066
- AgentPassage.metadata_,
2067
- AgentPassage.embedding,
2068
- AgentPassage.created_at,
2069
- AgentPassage.updated_at,
2070
- AgentPassage.is_deleted,
2071
- AgentPassage._created_by_id,
2072
- AgentPassage._last_updated_by_id,
2073
- AgentPassage.organization_id,
2074
- literal(None).label("file_id"),
2075
- literal(None).label("source_id"),
2076
- AgentPassage.agent_id,
2077
- )
2078
- .where(AgentPassage.agent_id == agent_id)
2079
- .where(AgentPassage.organization_id == actor.organization_id)
2080
- )
2081
-
2082
- # Combine queries
2083
- if source_passages is not None and agent_passages is not None:
2084
- combined_query = union_all(source_passages, agent_passages).cte("combined_passages")
2085
- elif agent_passages is not None:
2086
- combined_query = agent_passages.cte("combined_passages")
2087
- elif source_passages is not None:
2088
- combined_query = source_passages.cte("combined_passages")
2089
- else:
2090
- raise ValueError("No passages found")
2091
-
2092
- # Build main query from combined CTE
2093
- main_query = select(combined_query)
2094
-
2095
- # Apply filters
2096
- if start_date:
2097
- main_query = main_query.where(combined_query.c.created_at >= start_date)
2098
- if end_date:
2099
- main_query = main_query.where(combined_query.c.created_at <= end_date)
2100
- if source_id:
2101
- main_query = main_query.where(combined_query.c.source_id == source_id)
2102
- if file_id:
2103
- main_query = main_query.where(combined_query.c.file_id == file_id)
2104
-
2105
- # Vector search
2106
- if embedded_text:
2107
- if settings.letta_pg_uri_no_default:
2108
- # PostgreSQL with pgvector
2109
- main_query = main_query.order_by(combined_query.c.embedding.cosine_distance(embedded_text).asc())
2110
- else:
2111
- # SQLite with custom vector type
2112
- query_embedding_binary = adapt_array(embedded_text)
2113
- main_query = main_query.order_by(
2114
- func.cosine_distance(combined_query.c.embedding, query_embedding_binary).asc(),
2115
- combined_query.c.created_at.asc() if ascending else combined_query.c.created_at.desc(),
2116
- combined_query.c.id.asc(),
2117
- )
2118
- else:
2119
- if query_text:
2120
- main_query = main_query.where(func.lower(combined_query.c.text).contains(func.lower(query_text)))
2121
-
2122
- # Handle pagination
2123
- if before or after:
2124
- # Create reference CTEs
2125
- if before:
2126
- before_ref = select(combined_query.c.created_at, combined_query.c.id).where(combined_query.c.id == before).cte("before_ref")
2127
- if after:
2128
- after_ref = select(combined_query.c.created_at, combined_query.c.id).where(combined_query.c.id == after).cte("after_ref")
2129
-
2130
- if before and after:
2131
- # Window-based query (get records between before and after)
2132
- main_query = main_query.where(
2133
- or_(
2134
- combined_query.c.created_at < select(before_ref.c.created_at).scalar_subquery(),
2135
- and_(
2136
- combined_query.c.created_at == select(before_ref.c.created_at).scalar_subquery(),
2137
- combined_query.c.id < select(before_ref.c.id).scalar_subquery(),
2138
- ),
2139
- )
2140
- )
2141
- main_query = main_query.where(
2142
- or_(
2143
- combined_query.c.created_at > select(after_ref.c.created_at).scalar_subquery(),
2144
- and_(
2145
- combined_query.c.created_at == select(after_ref.c.created_at).scalar_subquery(),
2146
- combined_query.c.id > select(after_ref.c.id).scalar_subquery(),
2147
- ),
2148
- )
2149
- )
2150
- else:
2151
- # Pure pagination (only before or only after)
2152
- if before:
2153
- main_query = main_query.where(
2154
- or_(
2155
- combined_query.c.created_at < select(before_ref.c.created_at).scalar_subquery(),
2156
- and_(
2157
- combined_query.c.created_at == select(before_ref.c.created_at).scalar_subquery(),
2158
- combined_query.c.id < select(before_ref.c.id).scalar_subquery(),
2159
- ),
2160
- )
2161
- )
2162
- if after:
2163
- main_query = main_query.where(
2164
- or_(
2165
- combined_query.c.created_at > select(after_ref.c.created_at).scalar_subquery(),
2166
- and_(
2167
- combined_query.c.created_at == select(after_ref.c.created_at).scalar_subquery(),
2168
- combined_query.c.id > select(after_ref.c.id).scalar_subquery(),
2169
- ),
2170
- )
2171
- )
2172
-
2173
- # Add ordering if not already ordered by similarity
2174
- if not embed_query:
2175
- if ascending:
2176
- main_query = main_query.order_by(
2177
- combined_query.c.created_at.asc(),
2178
- combined_query.c.id.asc(),
2179
- )
2180
- else:
2181
- main_query = main_query.order_by(
2182
- combined_query.c.created_at.desc(),
2183
- combined_query.c.id.asc(),
2184
- )
2185
-
2186
- return main_query
2187
2026
 
2188
2027
  @trace_method
2189
2028
  @enforce_types
@@ -2206,7 +2045,7 @@ class AgentManager:
2206
2045
  ) -> List[PydanticPassage]:
2207
2046
  """Lists all passages attached to an agent."""
2208
2047
  with db_registry.session() as session:
2209
- main_query = self._build_passage_query(
2048
+ main_query = build_passage_query(
2210
2049
  actor=actor,
2211
2050
  agent_id=agent_id,
2212
2051
  file_id=file_id,
@@ -2266,7 +2105,7 @@ class AgentManager:
2266
2105
  ) -> List[PydanticPassage]:
2267
2106
  """Lists all passages attached to an agent."""
2268
2107
  async with db_registry.async_session() as session:
2269
- main_query = self._build_passage_query(
2108
+ main_query = build_passage_query(
2270
2109
  actor=actor,
2271
2110
  agent_id=agent_id,
2272
2111
  file_id=file_id,
@@ -2305,6 +2144,100 @@ class AgentManager:
2305
2144
 
2306
2145
  return [p.to_pydantic() for p in passages]
2307
2146
 
2147
+ @trace_method
2148
+ @enforce_types
2149
+ async def list_source_passages_async(
2150
+ self,
2151
+ actor: PydanticUser,
2152
+ agent_id: Optional[str] = None,
2153
+ file_id: Optional[str] = None,
2154
+ limit: Optional[int] = 50,
2155
+ query_text: Optional[str] = None,
2156
+ start_date: Optional[datetime] = None,
2157
+ end_date: Optional[datetime] = None,
2158
+ before: Optional[str] = None,
2159
+ after: Optional[str] = None,
2160
+ source_id: Optional[str] = None,
2161
+ embed_query: bool = False,
2162
+ ascending: bool = True,
2163
+ embedding_config: Optional[EmbeddingConfig] = None,
2164
+ ) -> List[PydanticPassage]:
2165
+ """Lists all passages attached to an agent."""
2166
+ async with db_registry.async_session() as session:
2167
+ main_query = build_source_passage_query(
2168
+ actor=actor,
2169
+ agent_id=agent_id,
2170
+ file_id=file_id,
2171
+ query_text=query_text,
2172
+ start_date=start_date,
2173
+ end_date=end_date,
2174
+ before=before,
2175
+ after=after,
2176
+ source_id=source_id,
2177
+ embed_query=embed_query,
2178
+ ascending=ascending,
2179
+ embedding_config=embedding_config,
2180
+ )
2181
+
2182
+ # Add limit
2183
+ if limit:
2184
+ main_query = main_query.limit(limit)
2185
+
2186
+ # Execute query
2187
+ result = await session.execute(main_query)
2188
+
2189
+ # Get ORM objects directly using scalars()
2190
+ passages = result.scalars().all()
2191
+
2192
+ # Convert to Pydantic models
2193
+ return [p.to_pydantic() for p in passages]
2194
+
2195
+ @trace_method
2196
+ @enforce_types
2197
+ async def list_agent_passages_async(
2198
+ self,
2199
+ actor: PydanticUser,
2200
+ agent_id: Optional[str] = None,
2201
+ file_id: Optional[str] = None,
2202
+ limit: Optional[int] = 50,
2203
+ query_text: Optional[str] = None,
2204
+ start_date: Optional[datetime] = None,
2205
+ end_date: Optional[datetime] = None,
2206
+ before: Optional[str] = None,
2207
+ after: Optional[str] = None,
2208
+ source_id: Optional[str] = None,
2209
+ embed_query: bool = False,
2210
+ ascending: bool = True,
2211
+ embedding_config: Optional[EmbeddingConfig] = None,
2212
+ ) -> List[PydanticPassage]:
2213
+ """Lists all passages attached to an agent."""
2214
+ async with db_registry.async_session() as session:
2215
+ main_query = build_agent_passage_query(
2216
+ actor=actor,
2217
+ agent_id=agent_id,
2218
+ query_text=query_text,
2219
+ start_date=start_date,
2220
+ end_date=end_date,
2221
+ before=before,
2222
+ after=after,
2223
+ embed_query=embed_query,
2224
+ ascending=ascending,
2225
+ embedding_config=embedding_config,
2226
+ )
2227
+
2228
+ # Add limit
2229
+ if limit:
2230
+ main_query = main_query.limit(limit)
2231
+
2232
+ # Execute query
2233
+ result = await session.execute(main_query)
2234
+
2235
+ # Get ORM objects directly using scalars()
2236
+ passages = result.scalars().all()
2237
+
2238
+ # Convert to Pydantic models
2239
+ return [p.to_pydantic() for p in passages]
2240
+
2308
2241
  @trace_method
2309
2242
  @enforce_types
2310
2243
  def passage_size(
@@ -2325,7 +2258,7 @@ class AgentManager:
2325
2258
  ) -> int:
2326
2259
  """Returns the count of passages matching the given criteria."""
2327
2260
  with db_registry.session() as session:
2328
- main_query = self._build_passage_query(
2261
+ main_query = build_passage_query(
2329
2262
  actor=actor,
2330
2263
  agent_id=agent_id,
2331
2264
  file_id=file_id,
@@ -2363,7 +2296,7 @@ class AgentManager:
2363
2296
  agent_only: bool = False,
2364
2297
  ) -> int:
2365
2298
  async with db_registry.async_session() as session:
2366
- main_query = self._build_passage_query(
2299
+ main_query = build_passage_query(
2367
2300
  actor=actor,
2368
2301
  agent_id=agent_id,
2369
2302
  file_id=file_id,
@@ -2458,6 +2391,65 @@ class AgentManager:
2458
2391
  await agent.update_async(session, actor=actor)
2459
2392
  return await agent.to_pydantic_async()
2460
2393
 
2394
+ @trace_method
2395
+ @enforce_types
2396
+ async def attach_missing_files_tools_async(self, agent_state: PydanticAgentState, actor: PydanticUser) -> PydanticAgentState:
2397
+ """
2398
+ Attaches missing core file tools to an agent.
2399
+
2400
+ Args:
2401
+ agent_id: ID of the agent to attach the tools to.
2402
+ actor: User performing the action.
2403
+
2404
+ Raises:
2405
+ NoResultFound: If the agent or tool is not found.
2406
+
2407
+ Returns:
2408
+ PydanticAgentState: The updated agent state.
2409
+ """
2410
+ # Check if the agent is missing any files tools
2411
+ core_tool_names = {tool.name for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE}
2412
+ missing_tool_names = set(FILES_TOOLS).difference(core_tool_names)
2413
+
2414
+ for tool_name in missing_tool_names:
2415
+ tool_id = await self.tool_manager.get_tool_id_by_name_async(tool_name=tool_name, actor=actor)
2416
+
2417
+ # TODO: This is hacky and deserves a rethink - how do we keep all the base tools available in every org always?
2418
+ if not tool_id:
2419
+ await self.tool_manager.upsert_base_tools_async(actor=actor, allowed_types={ToolType.LETTA_FILES_CORE})
2420
+
2421
+ # TODO: Inefficient - I think this re-retrieves the agent_state?
2422
+ agent_state = await self.attach_tool_async(agent_id=agent_state.id, tool_id=tool_id, actor=actor)
2423
+
2424
+ return agent_state
2425
+
2426
+ @trace_method
2427
+ @enforce_types
2428
+ async def detach_all_files_tools_async(self, agent_state: PydanticAgentState, actor: PydanticUser) -> PydanticAgentState:
2429
+ """
2430
+ Detach all core file tools from an agent.
2431
+
2432
+ Args:
2433
+ agent_id: ID of the agent to detach the tools from.
2434
+ actor: User performing the action.
2435
+
2436
+ Raises:
2437
+ NoResultFound: If the agent or tool is not found.
2438
+
2439
+ Returns:
2440
+ PydanticAgentState: The updated agent state.
2441
+ """
2442
+ # Check if the agent is missing any files tools
2443
+ core_tool_names = {tool.name for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE}
2444
+
2445
+ for tool_name in core_tool_names:
2446
+ tool_id = await self.tool_manager.get_tool_id_by_name_async(tool_name=tool_name, actor=actor)
2447
+
2448
+ # TODO: Inefficient - I think this re-retrieves the agent_state?
2449
+ agent_state = await self.detach_tool_async(agent_id=agent_state.id, tool_id=tool_id, actor=actor)
2450
+
2451
+ return agent_state
2452
+
2461
2453
  @trace_method
2462
2454
  @enforce_types
2463
2455
  def detach_tool(self, agent_id: str, tool_id: str, actor: PydanticUser) -> PydanticAgentState:
@@ -9,12 +9,12 @@ from letta.orm.block import Block as BlockModel
9
9
  from letta.orm.block_history import BlockHistory
10
10
  from letta.orm.enums import ActorType
11
11
  from letta.orm.errors import NoResultFound
12
+ from letta.otel.tracing import trace_method
12
13
  from letta.schemas.agent import AgentState as PydanticAgentState
13
14
  from letta.schemas.block import Block as PydanticBlock
14
15
  from letta.schemas.block import BlockUpdate
15
16
  from letta.schemas.user import User as PydanticUser
16
17
  from letta.server.db import db_registry
17
- from letta.tracing import trace_method
18
18
  from letta.utils import enforce_types
19
19
 
20
20
  logger = get_logger(__name__)
@@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
2
2
  from typing import Any, Dict, List
3
3
 
4
4
  from letta.llm_api.anthropic_client import AnthropicClient
5
+ from letta.schemas.openai.chat_completion_request import Tool as OpenAITool
5
6
  from letta.utils import count_tokens
6
7
 
7
8
 
@@ -42,7 +43,7 @@ class AnthropicTokenCounter(TokenCounter):
42
43
  return 0
43
44
  return await self.client.count_tokens(model=self.model, messages=messages)
44
45
 
45
- async def count_tool_tokens(self, tools: List[Any]) -> int:
46
+ async def count_tool_tokens(self, tools: List[OpenAITool]) -> int:
46
47
  if not tools:
47
48
  return 0
48
49
  return await self.client.count_tokens(model=self.model, tools=tools)
@@ -69,7 +70,7 @@ class TiktokenCounter(TokenCounter):
69
70
 
70
71
  return num_tokens_from_messages(messages=messages, model=self.model)
71
72
 
72
- async def count_tool_tokens(self, tools: List[Any]) -> int:
73
+ async def count_tool_tokens(self, tools: List[OpenAITool]) -> int:
73
74
  if not tools:
74
75
  return 0
75
76
  from letta.local_llm.utils import num_tokens_from_functions
@@ -0,0 +1,34 @@
1
+ from typing import List, Optional
2
+
3
+ from letta.log import get_logger
4
+
5
+ logger = get_logger(__name__)
6
+
7
+
8
+ class LineChunker:
9
+ """Newline chunker"""
10
+
11
+ def __init__(self):
12
+ pass
13
+
14
+ # TODO: Make this more general beyond Mistral
15
+ def chunk_text(self, text: str, start: Optional[int] = None, end: Optional[int] = None) -> List[str]:
16
+ """Split lines"""
17
+ content_lines = [line.strip() for line in text.split("\n") if line.strip()]
18
+ total_lines = len(content_lines)
19
+
20
+ if start and end:
21
+ content_lines = content_lines[start:end]
22
+ line_offset = start
23
+ else:
24
+ line_offset = 0
25
+
26
+ content_lines = [f"Line {i + line_offset}: {line}" for i, line in enumerate(content_lines)]
27
+
28
+ # Add metadata about total lines
29
+ if start and end:
30
+ content_lines.insert(0, f"[Viewing lines {start} to {end} (out of {total_lines} lines)]")
31
+ else:
32
+ content_lines.insert(0, f"[Viewing file start (out of {total_lines} lines)]")
33
+
34
+ return content_lines
@@ -5,12 +5,13 @@ from fastapi import UploadFile
5
5
 
6
6
  from letta.log import get_logger
7
7
  from letta.schemas.agent import AgentState
8
- from letta.schemas.enums import JobStatus
8
+ from letta.schemas.enums import FileProcessingStatus, JobStatus
9
9
  from letta.schemas.file import FileMetadata
10
10
  from letta.schemas.job import Job, JobUpdate
11
11
  from letta.schemas.passage import Passage
12
12
  from letta.schemas.user import User
13
13
  from letta.server.server import SyncServer
14
+ from letta.services.file_processor.chunker.line_chunker import LineChunker
14
15
  from letta.services.file_processor.chunker.llama_index_chunker import LlamaIndexChunker
15
16
  from letta.services.file_processor.embedder.openai_embedder import OpenAIEmbedder
16
17
  from letta.services.file_processor.parser.mistral_parser import MistralFileParser
@@ -34,6 +35,7 @@ class FileProcessor:
34
35
  ):
35
36
  self.file_parser = file_parser
36
37
  self.text_chunker = text_chunker
38
+ self.line_chunker = LineChunker()
37
39
  self.embedder = embedder
38
40
  self.max_file_size = max_file_size
39
41
  self.source_manager = SourceManager()
@@ -52,9 +54,12 @@ class FileProcessor:
52
54
  job: Optional[Job] = None,
53
55
  ) -> List[Passage]:
54
56
  file_metadata = self._extract_upload_file_metadata(file, source_id=source_id)
55
- file_metadata = await self.source_manager.create_file(file_metadata, self.actor)
56
57
  filename = file_metadata.file_name
57
58
 
59
+ # Create file as early as possible with no content
60
+ file_metadata.processing_status = FileProcessingStatus.PARSING # Parsing now
61
+ file_metadata = await self.source_manager.create_file(file_metadata, self.actor)
62
+
58
63
  try:
59
64
  # Ensure we're working with bytes
60
65
  if isinstance(content, str):
@@ -66,11 +71,35 @@ class FileProcessor:
66
71
  logger.info(f"Starting OCR extraction for {filename}")
67
72
  ocr_response = await self.file_parser.extract_text(content, mime_type=file_metadata.file_type)
68
73
 
74
+ # update file with raw text
75
+ raw_markdown_text = "".join([page.markdown for page in ocr_response.pages])
76
+ file_metadata = await self.source_manager.upsert_file_content(
77
+ file_id=file_metadata.id, text=raw_markdown_text, actor=self.actor
78
+ )
79
+ file_metadata = await self.source_manager.update_file_status(
80
+ file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.EMBEDDING
81
+ )
82
+
83
+ # Insert to agent context window
84
+ # TODO: Rethink this line chunking mechanism
85
+ content_lines = self.line_chunker.chunk_text(text=raw_markdown_text)
86
+ visible_content = "\n".join(content_lines)
87
+
88
+ await server.insert_file_into_context_windows(
89
+ source_id=source_id,
90
+ text=visible_content,
91
+ file_id=file_metadata.id,
92
+ file_name=file_metadata.file_name,
93
+ actor=self.actor,
94
+ agent_states=agent_states,
95
+ )
96
+
69
97
  if not ocr_response or len(ocr_response.pages) == 0:
70
98
  raise ValueError("No text extracted from PDF")
71
99
 
72
100
  logger.info("Chunking extracted text")
73
101
  all_passages = []
102
+
74
103
  for page in ocr_response.pages:
75
104
  chunks = self.text_chunker.chunk_text(page)
76
105
 
@@ -86,24 +115,20 @@ class FileProcessor:
86
115
 
87
116
  logger.info(f"Successfully processed {filename}: {len(all_passages)} passages")
88
117
 
89
- await server.insert_file_into_context_windows(
90
- source_id=source_id,
91
- text="".join([ocr_response.pages[i].markdown for i in range(min(3, len(ocr_response.pages)))]),
92
- file_id=file_metadata.id,
93
- actor=self.actor,
94
- agent_states=agent_states,
95
- )
96
-
97
118
  # update job status
98
119
  if job:
99
120
  job.status = JobStatus.completed
100
121
  job.metadata["num_passages"] = len(all_passages)
101
122
  await self.job_manager.update_job_by_id_async(job_id=job.id, job_update=JobUpdate(**job.model_dump()), actor=self.actor)
102
123
 
124
+ await self.source_manager.update_file_status(
125
+ file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.COMPLETED
126
+ )
127
+
103
128
  return all_passages
104
129
 
105
130
  except Exception as e:
106
- logger.error(f"PDF processing failed for {filename}: {str(e)}")
131
+ logger.error(f"File processing failed for {filename}: {str(e)}")
107
132
 
108
133
  # update job status
109
134
  if job:
@@ -111,6 +136,10 @@ class FileProcessor:
111
136
  job.metadata["error"] = str(e)
112
137
  await self.job_manager.update_job_by_id_async(job_id=job.id, job_update=JobUpdate(**job.model_dump()), actor=self.actor)
113
138
 
139
+ await self.source_manager.update_file_status(
140
+ file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.ERROR, error_message=str(e)
141
+ )
142
+
114
143
  return []
115
144
 
116
145
  def _extract_upload_file_metadata(self, file: UploadFile, source_id: str) -> FileMetadata: