letta-nightly 0.11.6.dev20250903104037__py3-none-any.whl → 0.11.7.dev20250904045700__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 (138) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +10 -14
  3. letta/agents/base_agent.py +18 -0
  4. letta/agents/helpers.py +32 -7
  5. letta/agents/letta_agent.py +953 -762
  6. letta/agents/voice_agent.py +1 -1
  7. letta/client/streaming.py +0 -1
  8. letta/constants.py +11 -8
  9. letta/errors.py +9 -0
  10. letta/functions/function_sets/base.py +77 -69
  11. letta/functions/function_sets/builtin.py +41 -22
  12. letta/functions/function_sets/multi_agent.py +1 -2
  13. letta/functions/schema_generator.py +0 -1
  14. letta/helpers/converters.py +8 -3
  15. letta/helpers/datetime_helpers.py +5 -4
  16. letta/helpers/message_helper.py +1 -2
  17. letta/helpers/pinecone_utils.py +0 -1
  18. letta/helpers/tool_rule_solver.py +10 -0
  19. letta/helpers/tpuf_client.py +848 -0
  20. letta/interface.py +8 -8
  21. letta/interfaces/anthropic_streaming_interface.py +7 -0
  22. letta/interfaces/openai_streaming_interface.py +29 -6
  23. letta/llm_api/anthropic_client.py +188 -18
  24. letta/llm_api/azure_client.py +0 -1
  25. letta/llm_api/bedrock_client.py +1 -2
  26. letta/llm_api/deepseek_client.py +319 -5
  27. letta/llm_api/google_vertex_client.py +75 -17
  28. letta/llm_api/groq_client.py +0 -1
  29. letta/llm_api/helpers.py +2 -2
  30. letta/llm_api/llm_api_tools.py +1 -50
  31. letta/llm_api/llm_client.py +6 -8
  32. letta/llm_api/mistral.py +1 -1
  33. letta/llm_api/openai.py +16 -13
  34. letta/llm_api/openai_client.py +31 -16
  35. letta/llm_api/together_client.py +0 -1
  36. letta/llm_api/xai_client.py +0 -1
  37. letta/local_llm/chat_completion_proxy.py +7 -6
  38. letta/local_llm/settings/settings.py +1 -1
  39. letta/orm/__init__.py +1 -0
  40. letta/orm/agent.py +8 -6
  41. letta/orm/archive.py +9 -1
  42. letta/orm/block.py +3 -4
  43. letta/orm/block_history.py +3 -1
  44. letta/orm/group.py +2 -3
  45. letta/orm/identity.py +1 -2
  46. letta/orm/job.py +1 -2
  47. letta/orm/llm_batch_items.py +1 -2
  48. letta/orm/message.py +8 -4
  49. letta/orm/mixins.py +18 -0
  50. letta/orm/organization.py +2 -0
  51. letta/orm/passage.py +8 -1
  52. letta/orm/passage_tag.py +55 -0
  53. letta/orm/sandbox_config.py +1 -3
  54. letta/orm/step.py +1 -2
  55. letta/orm/tool.py +1 -0
  56. letta/otel/resource.py +2 -2
  57. letta/plugins/plugins.py +1 -1
  58. letta/prompts/prompt_generator.py +10 -2
  59. letta/schemas/agent.py +11 -0
  60. letta/schemas/archive.py +4 -0
  61. letta/schemas/block.py +13 -0
  62. letta/schemas/embedding_config.py +0 -1
  63. letta/schemas/enums.py +24 -7
  64. letta/schemas/group.py +12 -0
  65. letta/schemas/letta_message.py +55 -1
  66. letta/schemas/letta_message_content.py +28 -0
  67. letta/schemas/letta_request.py +21 -4
  68. letta/schemas/letta_stop_reason.py +9 -1
  69. letta/schemas/llm_config.py +24 -8
  70. letta/schemas/mcp.py +0 -3
  71. letta/schemas/memory.py +14 -0
  72. letta/schemas/message.py +245 -141
  73. letta/schemas/openai/chat_completion_request.py +2 -1
  74. letta/schemas/passage.py +1 -0
  75. letta/schemas/providers/bedrock.py +1 -1
  76. letta/schemas/providers/openai.py +2 -2
  77. letta/schemas/tool.py +11 -5
  78. letta/schemas/tool_execution_result.py +0 -1
  79. letta/schemas/tool_rule.py +71 -0
  80. letta/serialize_schemas/marshmallow_agent.py +1 -2
  81. letta/server/rest_api/app.py +3 -3
  82. letta/server/rest_api/auth/index.py +0 -1
  83. letta/server/rest_api/interface.py +3 -11
  84. letta/server/rest_api/redis_stream_manager.py +3 -4
  85. letta/server/rest_api/routers/v1/agents.py +143 -84
  86. letta/server/rest_api/routers/v1/blocks.py +1 -1
  87. letta/server/rest_api/routers/v1/folders.py +1 -1
  88. letta/server/rest_api/routers/v1/groups.py +23 -22
  89. letta/server/rest_api/routers/v1/internal_templates.py +68 -0
  90. letta/server/rest_api/routers/v1/sandbox_configs.py +11 -5
  91. letta/server/rest_api/routers/v1/sources.py +1 -1
  92. letta/server/rest_api/routers/v1/tools.py +167 -15
  93. letta/server/rest_api/streaming_response.py +4 -3
  94. letta/server/rest_api/utils.py +75 -18
  95. letta/server/server.py +24 -35
  96. letta/services/agent_manager.py +359 -45
  97. letta/services/agent_serialization_manager.py +23 -3
  98. letta/services/archive_manager.py +72 -3
  99. letta/services/block_manager.py +1 -2
  100. letta/services/context_window_calculator/token_counter.py +11 -6
  101. letta/services/file_manager.py +1 -3
  102. letta/services/files_agents_manager.py +2 -4
  103. letta/services/group_manager.py +73 -12
  104. letta/services/helpers/agent_manager_helper.py +5 -5
  105. letta/services/identity_manager.py +8 -3
  106. letta/services/job_manager.py +2 -14
  107. letta/services/llm_batch_manager.py +1 -3
  108. letta/services/mcp/base_client.py +1 -2
  109. letta/services/mcp_manager.py +5 -6
  110. letta/services/message_manager.py +536 -15
  111. letta/services/organization_manager.py +1 -2
  112. letta/services/passage_manager.py +287 -12
  113. letta/services/provider_manager.py +1 -3
  114. letta/services/sandbox_config_manager.py +12 -7
  115. letta/services/source_manager.py +1 -2
  116. letta/services/step_manager.py +0 -1
  117. letta/services/summarizer/summarizer.py +4 -2
  118. letta/services/telemetry_manager.py +1 -3
  119. letta/services/tool_executor/builtin_tool_executor.py +136 -316
  120. letta/services/tool_executor/core_tool_executor.py +231 -74
  121. letta/services/tool_executor/files_tool_executor.py +2 -2
  122. letta/services/tool_executor/mcp_tool_executor.py +0 -1
  123. letta/services/tool_executor/multi_agent_tool_executor.py +2 -2
  124. letta/services/tool_executor/sandbox_tool_executor.py +0 -1
  125. letta/services/tool_executor/tool_execution_sandbox.py +2 -3
  126. letta/services/tool_manager.py +181 -64
  127. letta/services/tool_sandbox/modal_deployment_manager.py +2 -2
  128. letta/services/user_manager.py +1 -2
  129. letta/settings.py +5 -3
  130. letta/streaming_interface.py +3 -3
  131. letta/system.py +1 -1
  132. letta/utils.py +0 -1
  133. {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/METADATA +11 -7
  134. {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/RECORD +137 -135
  135. letta/llm_api/deepseek.py +0 -303
  136. {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/WHEEL +0 -0
  137. {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/entry_points.txt +0 -0
  138. {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
- import os
3
2
  from datetime import datetime, timezone
4
- from typing import Any, Dict, List, Optional, Set, Tuple
3
+ from typing import Any, Dict, List, Literal, Optional, Set, Tuple
4
+ from zoneinfo import ZoneInfo
5
5
 
6
6
  import sqlalchemy as sa
7
7
  from sqlalchemy import delete, func, insert, literal, or_, select, tuple_
@@ -22,52 +22,59 @@ from letta.constants import (
22
22
  EXCLUDE_MODEL_KEYWORDS_FROM_BASE_TOOL_RULES,
23
23
  FILES_TOOLS,
24
24
  INCLUDE_MODEL_KEYWORDS_BASE_TOOL_RULES,
25
+ RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE,
25
26
  )
26
27
  from letta.helpers import ToolRulesSolver
27
28
  from letta.helpers.datetime_helpers import get_utc_time
28
29
  from letta.llm_api.llm_client import LLMClient
29
30
  from letta.log import get_logger
30
- from letta.orm import Agent as AgentModel
31
- from letta.orm import AgentsTags, ArchivalPassage
32
- from letta.orm import Block as BlockModel
33
- from letta.orm import BlocksAgents
34
- from letta.orm import Group as GroupModel
35
- from letta.orm import GroupsAgents, IdentitiesAgents
36
- from letta.orm import Source as SourceModel
37
- from letta.orm import SourcePassage, SourcesAgents
38
- from letta.orm import Tool as ToolModel
39
- from letta.orm import ToolsAgents
31
+ from letta.orm import (
32
+ Agent as AgentModel,
33
+ AgentsTags,
34
+ ArchivalPassage,
35
+ Block as BlockModel,
36
+ BlocksAgents,
37
+ Group as GroupModel,
38
+ GroupsAgents,
39
+ IdentitiesAgents,
40
+ Source as SourceModel,
41
+ SourcePassage,
42
+ SourcesAgents,
43
+ Tool as ToolModel,
44
+ ToolsAgents,
45
+ )
40
46
  from letta.orm.errors import NoResultFound
41
- from letta.orm.sandbox_config import AgentEnvironmentVariable
42
- from letta.orm.sandbox_config import AgentEnvironmentVariable as AgentEnvironmentVariableModel
47
+ from letta.orm.sandbox_config import AgentEnvironmentVariable, AgentEnvironmentVariable as AgentEnvironmentVariableModel
43
48
  from letta.orm.sqlalchemy_base import AccessType
44
49
  from letta.otel.tracing import trace_method
45
50
  from letta.prompts.prompt_generator import PromptGenerator
46
- from letta.schemas.agent import AgentState as PydanticAgentState
47
- from letta.schemas.agent import AgentType, CreateAgent, UpdateAgent, get_prompt_template_for_agent_type
48
- from letta.schemas.block import DEFAULT_BLOCKS
49
- from letta.schemas.block import Block as PydanticBlock
50
- from letta.schemas.block import BlockUpdate
51
+ from letta.schemas.agent import (
52
+ AgentState as PydanticAgentState,
53
+ AgentType,
54
+ CreateAgent,
55
+ InternalTemplateAgentCreate,
56
+ UpdateAgent,
57
+ get_prompt_template_for_agent_type,
58
+ )
59
+ from letta.schemas.block import DEFAULT_BLOCKS, Block as PydanticBlock, BlockUpdate
51
60
  from letta.schemas.embedding_config import EmbeddingConfig
52
- from letta.schemas.enums import ProviderType, ToolType
61
+ from letta.schemas.enums import ProviderType, TagMatchMode, ToolType, VectorDBProvider
53
62
  from letta.schemas.file import FileMetadata as PydanticFileMetadata
54
- from letta.schemas.group import Group as PydanticGroup
55
- from letta.schemas.group import ManagerType
63
+ from letta.schemas.group import Group as PydanticGroup, ManagerType
56
64
  from letta.schemas.llm_config import LLMConfig
57
65
  from letta.schemas.memory import ContextWindowOverview, Memory
58
- from letta.schemas.message import Message
59
- from letta.schemas.message import Message as PydanticMessage
60
- from letta.schemas.message import MessageCreate, MessageUpdate
66
+ from letta.schemas.message import Message, Message as PydanticMessage, MessageCreate, MessageUpdate
61
67
  from letta.schemas.passage import Passage as PydanticPassage
62
68
  from letta.schemas.source import Source as PydanticSource
63
69
  from letta.schemas.tool import Tool as PydanticTool
64
- from letta.schemas.tool_rule import ContinueToolRule, TerminalToolRule
70
+ from letta.schemas.tool_rule import ContinueToolRule, RequiresApprovalToolRule, TerminalToolRule
65
71
  from letta.schemas.user import User as PydanticUser
66
72
  from letta.serialize_schemas import MarshmallowAgentSchema
67
73
  from letta.serialize_schemas.marshmallow_message import SerializedMessageSchema
68
74
  from letta.serialize_schemas.marshmallow_tool import SerializedToolSchema
69
75
  from letta.serialize_schemas.pydantic_agent_schema import AgentSchema
70
76
  from letta.server.db import db_registry
77
+ from letta.services.archive_manager import ArchiveManager
71
78
  from letta.services.block_manager import BlockManager
72
79
  from letta.services.context_window_calculator.context_window_calculator import ContextWindowCalculator
73
80
  from letta.services.context_window_calculator.token_counter import AnthropicTokenCounter, TiktokenCounter
@@ -117,6 +124,7 @@ class AgentManager:
117
124
  self.passage_manager = PassageManager()
118
125
  self.identity_manager = IdentityManager()
119
126
  self.file_agent_manager = FileAgentManager()
127
+ self.archive_manager = ArchiveManager()
120
128
 
121
129
  @staticmethod
122
130
  def _should_exclude_model_from_base_tool_rules(model: str) -> bool:
@@ -162,14 +170,16 @@ class AgentManager:
162
170
  return name_to_id, id_to_name
163
171
 
164
172
  @staticmethod
165
- async def _resolve_tools_async(session, names: Set[str], ids: Set[str], org_id: str) -> Tuple[Dict[str, str], Dict[str, str]]:
173
+ async def _resolve_tools_async(
174
+ session, names: Set[str], ids: Set[str], org_id: str
175
+ ) -> Tuple[Dict[str, str], Dict[str, str], List[str]]:
166
176
  """
167
177
  Bulk‑fetch all ToolModel rows matching either name ∈ names or id ∈ ids
168
178
  (and scoped to this organization), and return two maps:
169
179
  name_to_id, id_to_name.
170
180
  Raises if any requested name or id was not found.
171
181
  """
172
- stmt = select(ToolModel.id, ToolModel.name).where(
182
+ stmt = select(ToolModel.id, ToolModel.name, ToolModel.default_requires_approval).where(
173
183
  ToolModel.organization_id == org_id,
174
184
  or_(
175
185
  ToolModel.name.in_(names),
@@ -180,6 +190,7 @@ class AgentManager:
180
190
  rows = result.fetchall() # Use fetchall()
181
191
  name_to_id = {row[1]: row[0] for row in rows} # row[1] is name, row[0] is id
182
192
  id_to_name = {row[0]: row[1] for row in rows} # row[0] is id, row[1] is name
193
+ requires_approval = [row[1] for row in rows if row[2]] # row[1] is name, row[2] is default_requires_approval
183
194
 
184
195
  missing_names = names - set(name_to_id.keys())
185
196
  missing_ids = ids - set(id_to_name.keys())
@@ -188,7 +199,7 @@ class AgentManager:
188
199
  if missing_ids:
189
200
  raise ValueError(f"Tools not found by id: {missing_ids}")
190
201
 
191
- return name_to_id, id_to_name
202
+ return name_to_id, id_to_name, requires_approval
192
203
 
193
204
  @staticmethod
194
205
  def _bulk_insert_pivot(session, table, rows: list[dict]):
@@ -402,6 +413,13 @@ class AgentManager:
402
413
  per_file_view_window_char_limit=agent_create.per_file_view_window_char_limit,
403
414
  )
404
415
 
416
+ # Set template fields for InternalTemplateAgentCreate (similar to group creation)
417
+ if isinstance(agent_create, InternalTemplateAgentCreate):
418
+ new_agent.base_template_id = agent_create.base_template_id
419
+ new_agent.template_id = agent_create.template_id
420
+ new_agent.deployment_id = agent_create.deployment_id
421
+ new_agent.entity_id = agent_create.entity_id
422
+
405
423
  if _test_only_force_id:
406
424
  new_agent.id = _test_only_force_id
407
425
 
@@ -482,7 +500,6 @@ class AgentManager:
482
500
  # blocks
483
501
  block_ids = list(agent_create.block_ids or [])
484
502
  if agent_create.memory_blocks:
485
-
486
503
  pydantic_blocks = [PydanticBlock(**b.model_dump(to_orm=True)) for b in agent_create.memory_blocks]
487
504
 
488
505
  # Inject a description for the default blocks if the user didn't specify them
@@ -548,7 +565,7 @@ class AgentManager:
548
565
  async with db_registry.async_session() as session:
549
566
  async with session.begin():
550
567
  # Note: This will need to be modified if _resolve_tools needs an async version
551
- name_to_id, id_to_name = await self._resolve_tools_async(
568
+ name_to_id, id_to_name, requires_approval = await self._resolve_tools_async(
552
569
  session,
553
570
  tool_names,
554
571
  supplied_ids,
@@ -580,6 +597,9 @@ class AgentManager:
580
597
  elif tn in (BASE_TOOLS + BASE_MEMORY_TOOLS + BASE_MEMORY_TOOLS_V2 + BASE_SLEEPTIME_TOOLS):
581
598
  tool_rules.append(ContinueToolRule(tool_name=tn))
582
599
 
600
+ for tool_with_requires_approval in requires_approval:
601
+ tool_rules.append(RequiresApprovalToolRule(tool_name=tool_with_requires_approval))
602
+
583
603
  if tool_rules:
584
604
  check_supports_structured_output(model=agent_create.llm_config.model, tool_rules=tool_rules)
585
605
 
@@ -611,6 +631,13 @@ class AgentManager:
611
631
  per_file_view_window_char_limit=agent_create.per_file_view_window_char_limit,
612
632
  )
613
633
 
634
+ # Set template fields for InternalTemplateAgentCreate (similar to group creation)
635
+ if isinstance(agent_create, InternalTemplateAgentCreate):
636
+ new_agent.base_template_id = agent_create.base_template_id
637
+ new_agent.template_id = agent_create.template_id
638
+ new_agent.deployment_id = agent_create.deployment_id
639
+ new_agent.entity_id = agent_create.entity_id
640
+
614
641
  if _test_only_force_id:
615
642
  new_agent.id = _test_only_force_id
616
643
 
@@ -692,7 +719,9 @@ class AgentManager:
692
719
 
693
720
  # Only create messages if we initialized with messages
694
721
  if not _init_with_no_messages:
695
- await self.message_manager.create_many_messages_async(pydantic_msgs=init_messages, actor=actor)
722
+ await self.message_manager.create_many_messages_async(
723
+ pydantic_msgs=init_messages, actor=actor, embedding_config=result.embedding_config
724
+ )
696
725
  return result
697
726
 
698
727
  @enforce_types
@@ -777,7 +806,6 @@ class AgentManager:
777
806
  agent_update: UpdateAgent,
778
807
  actor: PydanticUser,
779
808
  ) -> PydanticAgentState:
780
-
781
809
  new_tools = set(agent_update.tool_ids or [])
782
810
  new_sources = set(agent_update.source_ids or [])
783
811
  new_blocks = set(agent_update.block_ids or [])
@@ -785,7 +813,6 @@ class AgentManager:
785
813
  new_tags = set(agent_update.tags or [])
786
814
 
787
815
  with db_registry.session() as session, session.begin():
788
-
789
816
  agent: AgentModel = AgentModel.read(db_session=session, identifier=agent_id, actor=actor)
790
817
  agent.updated_at = datetime.now(timezone.utc)
791
818
  agent.last_updated_by_id = actor.id
@@ -902,7 +929,6 @@ class AgentManager:
902
929
  agent_update: UpdateAgent,
903
930
  actor: PydanticUser,
904
931
  ) -> PydanticAgentState:
905
-
906
932
  new_tools = set(agent_update.tool_ids or [])
907
933
  new_sources = set(agent_update.source_ids or [])
908
934
  new_blocks = set(agent_update.block_ids or [])
@@ -910,7 +936,6 @@ class AgentManager:
910
936
  new_tags = set(agent_update.tags or [])
911
937
 
912
938
  async with db_registry.async_session() as session, session.begin():
913
-
914
939
  agent: AgentModel = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
915
940
  agent.updated_at = datetime.now(timezone.utc)
916
941
  agent.last_updated_by_id = actor.id
@@ -1861,8 +1886,8 @@ class AgentManager:
1861
1886
  async def append_to_in_context_messages_async(
1862
1887
  self, messages: List[PydanticMessage], agent_id: str, actor: PydanticUser
1863
1888
  ) -> PydanticAgentState:
1864
- messages = await self.message_manager.create_many_messages_async(messages, actor=actor)
1865
1889
  agent = await self.get_agent_by_id_async(agent_id=agent_id, actor=actor)
1890
+ messages = await self.message_manager.create_many_messages_async(messages, actor=actor, embedding_config=agent.embedding_config)
1866
1891
  message_ids = agent.message_ids or []
1867
1892
  message_ids += [m.id for m in messages]
1868
1893
  return await self.set_in_context_messages_async(agent_id=agent_id, message_ids=message_ids, actor=actor)
@@ -2507,7 +2532,20 @@ class AgentManager:
2507
2532
  embedding_config: Optional[EmbeddingConfig] = None,
2508
2533
  agent_only: bool = False,
2509
2534
  ) -> List[PydanticPassage]:
2510
- """Lists all passages attached to an agent."""
2535
+ """
2536
+ DEPRECATED: Use query_source_passages_async or query_agent_passages_async instead.
2537
+ This method is kept only for test compatibility and will be removed in a future version.
2538
+
2539
+ Lists all passages attached to an agent (combines both source and agent passages).
2540
+ """
2541
+ import warnings
2542
+
2543
+ warnings.warn(
2544
+ "list_passages_async is deprecated. Use query_source_passages_async or query_agent_passages_async instead.",
2545
+ DeprecationWarning,
2546
+ stacklevel=2,
2547
+ )
2548
+
2511
2549
  async with db_registry.async_session() as session:
2512
2550
  main_query = await build_passage_query(
2513
2551
  actor=actor,
@@ -2554,7 +2592,7 @@ class AgentManager:
2554
2592
 
2555
2593
  @enforce_types
2556
2594
  @trace_method
2557
- async def list_source_passages_async(
2595
+ async def query_source_passages_async(
2558
2596
  self,
2559
2597
  actor: PydanticUser,
2560
2598
  agent_id: Optional[str] = None,
@@ -2602,7 +2640,7 @@ class AgentManager:
2602
2640
 
2603
2641
  @enforce_types
2604
2642
  @trace_method
2605
- async def list_agent_passages_async(
2643
+ async def query_agent_passages_async(
2606
2644
  self,
2607
2645
  actor: PydanticUser,
2608
2646
  agent_id: Optional[str] = None,
@@ -2615,8 +2653,57 @@ class AgentManager:
2615
2653
  embed_query: bool = False,
2616
2654
  ascending: bool = True,
2617
2655
  embedding_config: Optional[EmbeddingConfig] = None,
2656
+ tags: Optional[List[str]] = None,
2657
+ tag_match_mode: Optional[TagMatchMode] = None,
2618
2658
  ) -> List[PydanticPassage]:
2619
2659
  """Lists all passages attached to an agent."""
2660
+ # Check if we should use Turbopuffer for vector search
2661
+ if embed_query and agent_id and query_text and embedding_config:
2662
+ # Get archive IDs for the agent
2663
+ archive_ids = await self.get_agent_archive_ids_async(agent_id=agent_id, actor=actor)
2664
+
2665
+ if archive_ids:
2666
+ # TODO: Remove this restriction once we support multiple archives with mixed vector DB providers
2667
+ if len(archive_ids) > 1:
2668
+ raise ValueError(f"Agent {agent_id} has multiple archives, which is not yet supported for vector search")
2669
+
2670
+ # Get archive to check vector_db_provider
2671
+ archive = await self.archive_manager.get_archive_by_id_async(archive_id=archive_ids[0], actor=actor)
2672
+
2673
+ # Use Turbopuffer for vector search if archive is configured for TPUF
2674
+ if archive.vector_db_provider == VectorDBProvider.TPUF:
2675
+ from letta.helpers.tpuf_client import TurbopufferClient
2676
+ from letta.llm_api.llm_client import LLMClient
2677
+
2678
+ # Generate embedding for query
2679
+ embedding_client = LLMClient.create(
2680
+ provider_type=embedding_config.embedding_endpoint_type,
2681
+ actor=actor,
2682
+ )
2683
+ embeddings = await embedding_client.request_embeddings([query_text], embedding_config)
2684
+ query_embedding = embeddings[0]
2685
+
2686
+ # Query Turbopuffer - use hybrid search when text is available
2687
+ tpuf_client = TurbopufferClient()
2688
+ # use hybrid search to combine vector and full-text search
2689
+ passages_with_scores = await tpuf_client.query_passages(
2690
+ archive_id=archive_ids[0],
2691
+ query_embedding=query_embedding,
2692
+ query_text=query_text, # pass text for potential hybrid search
2693
+ search_mode="hybrid", # use hybrid mode for better results
2694
+ top_k=limit,
2695
+ tags=tags,
2696
+ tag_match_mode=tag_match_mode or TagMatchMode.ANY,
2697
+ start_date=start_date,
2698
+ end_date=end_date,
2699
+ )
2700
+
2701
+ # Return just the passages (without scores)
2702
+ return [passage for passage, _ in passages_with_scores]
2703
+ else:
2704
+ return []
2705
+
2706
+ # Fall back to SQL-based search for non-vector queries or NATIVE archives
2620
2707
  async with db_registry.async_session() as session:
2621
2708
  main_query = await build_agent_passage_query(
2622
2709
  actor=actor,
@@ -2642,7 +2729,151 @@ class AgentManager:
2642
2729
  passages = result.scalars().all()
2643
2730
 
2644
2731
  # Convert to Pydantic models
2645
- return [p.to_pydantic() for p in passages]
2732
+ pydantic_passages = [p.to_pydantic() for p in passages]
2733
+
2734
+ # TODO: Integrate tag filtering directly into the SQL query for better performance.
2735
+ # Currently using post-filtering which is less efficient but simpler to implement.
2736
+ # Future optimization: Add JOIN with passage_tags table and WHERE clause for tag filtering.
2737
+ if tags:
2738
+ filtered_passages = []
2739
+ for passage in pydantic_passages:
2740
+ if passage.tags:
2741
+ passage_tags = set(passage.tags)
2742
+ query_tags = set(tags)
2743
+
2744
+ if tag_match_mode == TagMatchMode.ALL:
2745
+ # ALL mode: passage must have all query tags
2746
+ if query_tags.issubset(passage_tags):
2747
+ filtered_passages.append(passage)
2748
+ else:
2749
+ # ANY mode (default): passage must have at least one query tag
2750
+ if query_tags.intersection(passage_tags):
2751
+ filtered_passages.append(passage)
2752
+
2753
+ return filtered_passages
2754
+
2755
+ return pydantic_passages
2756
+
2757
+ @enforce_types
2758
+ @trace_method
2759
+ async def search_agent_archival_memory_async(
2760
+ self,
2761
+ agent_id: str,
2762
+ actor: PydanticUser,
2763
+ query: str,
2764
+ tags: Optional[List[str]] = None,
2765
+ tag_match_mode: Literal["any", "all"] = "any",
2766
+ top_k: Optional[int] = None,
2767
+ start_datetime: Optional[str] = None,
2768
+ end_datetime: Optional[str] = None,
2769
+ ) -> Tuple[List[Dict[str, Any]], int]:
2770
+ """
2771
+ Search archival memory using semantic (embedding-based) search with optional temporal filtering.
2772
+
2773
+ This is a shared method used by both the agent tool and API endpoint to ensure consistent behavior.
2774
+
2775
+ Args:
2776
+ agent_id: ID of the agent whose archival memory to search
2777
+ actor: User performing the search
2778
+ query: String to search for using semantic similarity
2779
+ tags: Optional list of tags to filter search results
2780
+ tag_match_mode: How to match tags - "any" or "all"
2781
+ top_k: Maximum number of results to return
2782
+ start_datetime: Filter results after this datetime (ISO 8601 format)
2783
+ end_datetime: Filter results before this datetime (ISO 8601 format)
2784
+
2785
+ Returns:
2786
+ Tuple of (formatted_results, count)
2787
+ """
2788
+ # Handle empty or whitespace-only queries
2789
+ if not query or not query.strip():
2790
+ return [], 0
2791
+
2792
+ # Get the agent to access timezone and embedding config
2793
+ agent_state = await self.get_agent_by_id_async(agent_id=agent_id, actor=actor)
2794
+
2795
+ # Parse datetime parameters if provided
2796
+ start_date = None
2797
+ end_date = None
2798
+
2799
+ if start_datetime:
2800
+ try:
2801
+ # Try parsing as full datetime first (with time)
2802
+ start_date = datetime.fromisoformat(start_datetime)
2803
+ except ValueError:
2804
+ try:
2805
+ # Fall back to date-only format
2806
+ start_date = datetime.strptime(start_datetime, "%Y-%m-%d")
2807
+ # Set to beginning of day
2808
+ start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
2809
+ except ValueError:
2810
+ raise ValueError(
2811
+ f"Invalid start_datetime format: {start_datetime}. Use ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM)"
2812
+ )
2813
+
2814
+ # Apply agent's timezone if datetime is naive
2815
+ if start_date.tzinfo is None and agent_state.timezone:
2816
+ tz = ZoneInfo(agent_state.timezone)
2817
+ start_date = start_date.replace(tzinfo=tz)
2818
+
2819
+ if end_datetime:
2820
+ try:
2821
+ # Try parsing as full datetime first (with time)
2822
+ end_date = datetime.fromisoformat(end_datetime)
2823
+ except ValueError:
2824
+ try:
2825
+ # Fall back to date-only format
2826
+ end_date = datetime.strptime(end_datetime, "%Y-%m-%d")
2827
+ # Set to end of day for end dates
2828
+ end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999)
2829
+ except ValueError:
2830
+ raise ValueError(f"Invalid end_datetime format: {end_datetime}. Use ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM)")
2831
+
2832
+ # Apply agent's timezone if datetime is naive
2833
+ if end_date.tzinfo is None and agent_state.timezone:
2834
+ tz = ZoneInfo(agent_state.timezone)
2835
+ end_date = end_date.replace(tzinfo=tz)
2836
+
2837
+ # Convert string to TagMatchMode enum
2838
+ tag_mode = TagMatchMode.ANY if tag_match_mode == "any" else TagMatchMode.ALL
2839
+
2840
+ # Get results using existing passage query method
2841
+ limit = top_k if top_k is not None else RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE
2842
+ all_results = await self.query_agent_passages_async(
2843
+ actor=actor,
2844
+ agent_id=agent_id,
2845
+ query_text=query,
2846
+ limit=limit,
2847
+ embedding_config=agent_state.embedding_config,
2848
+ embed_query=True,
2849
+ tags=tags,
2850
+ tag_match_mode=tag_mode,
2851
+ start_date=start_date,
2852
+ end_date=end_date,
2853
+ )
2854
+
2855
+ # Format results to include tags with friendly timestamps
2856
+ formatted_results = []
2857
+ for result in all_results:
2858
+ # Format timestamp in agent's timezone if available
2859
+ timestamp = result.created_at
2860
+ if timestamp and agent_state.timezone:
2861
+ try:
2862
+ # Convert to agent's timezone
2863
+ tz = ZoneInfo(agent_state.timezone)
2864
+ local_time = timestamp.astimezone(tz)
2865
+ # Format as ISO string with timezone
2866
+ formatted_timestamp = local_time.isoformat()
2867
+ except Exception:
2868
+ # Fallback to ISO format if timezone conversion fails
2869
+ formatted_timestamp = str(timestamp)
2870
+ else:
2871
+ # Use ISO format if no timezone is set
2872
+ formatted_timestamp = str(timestamp) if timestamp else "Unknown"
2873
+
2874
+ formatted_results.append({"timestamp": formatted_timestamp, "content": result.text, "tags": result.tags or []})
2875
+
2876
+ return formatted_results, len(formatted_results)
2646
2877
 
2647
2878
  @enforce_types
2648
2879
  @trace_method
@@ -2784,12 +3015,15 @@ class AgentManager:
2784
3015
 
2785
3016
  # verify tool exists and belongs to organization in a single query with the insert
2786
3017
  # first, check if tool exists with correct organization
2787
- tool_check_query = select(func.count(ToolModel.id)).where(
3018
+ tool_check_query = select(ToolModel.name, ToolModel.default_requires_approval).where(
2788
3019
  ToolModel.id == tool_id, ToolModel.organization_id == actor.organization_id
2789
3020
  )
2790
- tool_result = await session.execute(tool_check_query)
2791
- if tool_result.scalar() == 0:
3021
+ result = await session.execute(tool_check_query)
3022
+ tool_rows = result.fetchall()
3023
+
3024
+ if len(tool_rows) == 0:
2792
3025
  raise NoResultFound(f"Tool with id={tool_id} not found in organization={actor.organization_id}")
3026
+ tool_name, default_requires_approval = tool_rows[0]
2793
3027
 
2794
3028
  # use postgresql on conflict or mysql on duplicate key update for atomic operation
2795
3029
  if settings.letta_pg_uri_no_default:
@@ -2813,6 +3047,17 @@ class AgentManager:
2813
3047
  else:
2814
3048
  logger.info(f"Tool id={tool_id} is already attached to agent id={agent_id}")
2815
3049
 
3050
+ if default_requires_approval:
3051
+ agent = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
3052
+ existing_rules = [rule for rule in agent.tool_rules if rule.tool_name == tool_name and rule.type == "requires_approval"]
3053
+ if len(existing_rules) == 0:
3054
+ # Create a new list to ensure SQLAlchemy detects the change
3055
+ # This is critical for JSON columns - modifying in place doesn't trigger change detection
3056
+ tool_rules = list(agent.tool_rules) if agent.tool_rules else []
3057
+ tool_rules.append(RequiresApprovalToolRule(tool_name=tool_name))
3058
+ agent.tool_rules = tool_rules
3059
+ session.add(agent)
3060
+
2816
3061
  await session.commit()
2817
3062
 
2818
3063
  @enforce_types
@@ -3079,6 +3324,33 @@ class AgentManager:
3079
3324
 
3080
3325
  await session.commit()
3081
3326
 
3327
+ @enforce_types
3328
+ @trace_method
3329
+ async def modify_approvals_async(self, agent_id: str, tool_name: str, requires_approval: bool, actor: PydanticUser) -> None:
3330
+ def is_target_rule(rule):
3331
+ return rule.tool_name == tool_name and rule.type == "requires_approval"
3332
+
3333
+ async with db_registry.async_session() as session:
3334
+ agent = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
3335
+ existing_rules = [rule for rule in agent.tool_rules if is_target_rule(rule)]
3336
+
3337
+ if len(existing_rules) == 1 and not requires_approval:
3338
+ tool_rules = [rule for rule in agent.tool_rules if not is_target_rule(rule)]
3339
+ elif len(existing_rules) == 0 and requires_approval:
3340
+ # Create a new list to ensure SQLAlchemy detects the change
3341
+ # This is critical for JSON columns - modifying in place doesn't trigger change detection
3342
+ tool_rules = list(agent.tool_rules) if agent.tool_rules else []
3343
+ tool_rules.append(RequiresApprovalToolRule(tool_name=tool_name))
3344
+ else:
3345
+ tool_rules = None
3346
+
3347
+ if tool_rules is None:
3348
+ return
3349
+
3350
+ agent.tool_rules = tool_rules
3351
+ session.add(agent)
3352
+ await session.commit()
3353
+
3082
3354
  @enforce_types
3083
3355
  @trace_method
3084
3356
  def list_attached_tools(self, agent_id: str, actor: PydanticUser) -> List[PydanticTool]:
@@ -3409,7 +3681,7 @@ class AgentManager:
3409
3681
  )
3410
3682
  calculator = ContextWindowCalculator()
3411
3683
 
3412
- if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION" or agent_state.llm_config.model_endpoint_type == "anthropic":
3684
+ if settings.environment == "PRODUCTION" or agent_state.llm_config.model_endpoint_type == "anthropic":
3413
3685
  anthropic_client = LLMClient.create(provider_type=ProviderType.anthropic, actor=actor)
3414
3686
  model = agent_state.llm_config.model if agent_state.llm_config.model_endpoint_type == "anthropic" else None
3415
3687
 
@@ -3426,3 +3698,45 @@ class AgentManager:
3426
3698
  num_archival_memories=num_archival_memories,
3427
3699
  num_messages=num_messages,
3428
3700
  )
3701
+
3702
+ async def get_or_set_vector_db_namespace_async(
3703
+ self,
3704
+ agent_id: str,
3705
+ organization_id: str,
3706
+ ) -> str:
3707
+ """Get the vector database namespace for an agent, creating it if it doesn't exist.
3708
+
3709
+ Args:
3710
+ agent_id: Agent ID to check/store namespace
3711
+ organization_id: Organization ID for namespace generation
3712
+
3713
+ Returns:
3714
+ The org-scoped namespace name
3715
+ """
3716
+ from sqlalchemy import update
3717
+
3718
+ from letta.settings import settings
3719
+
3720
+ async with db_registry.async_session() as session:
3721
+ # check if namespace already exists
3722
+ result = await session.execute(select(AgentModel._vector_db_namespace).where(AgentModel.id == agent_id))
3723
+ row = result.fetchone()
3724
+
3725
+ if row and row[0]:
3726
+ return row[0]
3727
+
3728
+ # TODO: In the future, we might use agent_id for sharding the namespace
3729
+ # For now, all messages in an org share the same namespace
3730
+
3731
+ # generate org-scoped namespace name
3732
+ environment = settings.environment
3733
+ if environment:
3734
+ namespace_name = f"messages_{organization_id}_{environment.lower()}"
3735
+ else:
3736
+ namespace_name = f"messages_{organization_id}"
3737
+
3738
+ # update the agent with the namespace (keeps agent-level tracking for future sharding)
3739
+ await session.execute(update(AgentModel).where(AgentModel.id == agent_id).values(_vector_db_namespace=namespace_name))
3740
+ await session.commit()
3741
+
3742
+ return namespace_name