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.
- letta/__init__.py +1 -1
- letta/agent.py +10 -14
- letta/agents/base_agent.py +18 -0
- letta/agents/helpers.py +32 -7
- letta/agents/letta_agent.py +953 -762
- letta/agents/voice_agent.py +1 -1
- letta/client/streaming.py +0 -1
- letta/constants.py +11 -8
- letta/errors.py +9 -0
- letta/functions/function_sets/base.py +77 -69
- letta/functions/function_sets/builtin.py +41 -22
- letta/functions/function_sets/multi_agent.py +1 -2
- letta/functions/schema_generator.py +0 -1
- letta/helpers/converters.py +8 -3
- letta/helpers/datetime_helpers.py +5 -4
- letta/helpers/message_helper.py +1 -2
- letta/helpers/pinecone_utils.py +0 -1
- letta/helpers/tool_rule_solver.py +10 -0
- letta/helpers/tpuf_client.py +848 -0
- letta/interface.py +8 -8
- letta/interfaces/anthropic_streaming_interface.py +7 -0
- letta/interfaces/openai_streaming_interface.py +29 -6
- letta/llm_api/anthropic_client.py +188 -18
- letta/llm_api/azure_client.py +0 -1
- letta/llm_api/bedrock_client.py +1 -2
- letta/llm_api/deepseek_client.py +319 -5
- letta/llm_api/google_vertex_client.py +75 -17
- letta/llm_api/groq_client.py +0 -1
- letta/llm_api/helpers.py +2 -2
- letta/llm_api/llm_api_tools.py +1 -50
- letta/llm_api/llm_client.py +6 -8
- letta/llm_api/mistral.py +1 -1
- letta/llm_api/openai.py +16 -13
- letta/llm_api/openai_client.py +31 -16
- letta/llm_api/together_client.py +0 -1
- letta/llm_api/xai_client.py +0 -1
- letta/local_llm/chat_completion_proxy.py +7 -6
- letta/local_llm/settings/settings.py +1 -1
- letta/orm/__init__.py +1 -0
- letta/orm/agent.py +8 -6
- letta/orm/archive.py +9 -1
- letta/orm/block.py +3 -4
- letta/orm/block_history.py +3 -1
- letta/orm/group.py +2 -3
- letta/orm/identity.py +1 -2
- letta/orm/job.py +1 -2
- letta/orm/llm_batch_items.py +1 -2
- letta/orm/message.py +8 -4
- letta/orm/mixins.py +18 -0
- letta/orm/organization.py +2 -0
- letta/orm/passage.py +8 -1
- letta/orm/passage_tag.py +55 -0
- letta/orm/sandbox_config.py +1 -3
- letta/orm/step.py +1 -2
- letta/orm/tool.py +1 -0
- letta/otel/resource.py +2 -2
- letta/plugins/plugins.py +1 -1
- letta/prompts/prompt_generator.py +10 -2
- letta/schemas/agent.py +11 -0
- letta/schemas/archive.py +4 -0
- letta/schemas/block.py +13 -0
- letta/schemas/embedding_config.py +0 -1
- letta/schemas/enums.py +24 -7
- letta/schemas/group.py +12 -0
- letta/schemas/letta_message.py +55 -1
- letta/schemas/letta_message_content.py +28 -0
- letta/schemas/letta_request.py +21 -4
- letta/schemas/letta_stop_reason.py +9 -1
- letta/schemas/llm_config.py +24 -8
- letta/schemas/mcp.py +0 -3
- letta/schemas/memory.py +14 -0
- letta/schemas/message.py +245 -141
- letta/schemas/openai/chat_completion_request.py +2 -1
- letta/schemas/passage.py +1 -0
- letta/schemas/providers/bedrock.py +1 -1
- letta/schemas/providers/openai.py +2 -2
- letta/schemas/tool.py +11 -5
- letta/schemas/tool_execution_result.py +0 -1
- letta/schemas/tool_rule.py +71 -0
- letta/serialize_schemas/marshmallow_agent.py +1 -2
- letta/server/rest_api/app.py +3 -3
- letta/server/rest_api/auth/index.py +0 -1
- letta/server/rest_api/interface.py +3 -11
- letta/server/rest_api/redis_stream_manager.py +3 -4
- letta/server/rest_api/routers/v1/agents.py +143 -84
- letta/server/rest_api/routers/v1/blocks.py +1 -1
- letta/server/rest_api/routers/v1/folders.py +1 -1
- letta/server/rest_api/routers/v1/groups.py +23 -22
- letta/server/rest_api/routers/v1/internal_templates.py +68 -0
- letta/server/rest_api/routers/v1/sandbox_configs.py +11 -5
- letta/server/rest_api/routers/v1/sources.py +1 -1
- letta/server/rest_api/routers/v1/tools.py +167 -15
- letta/server/rest_api/streaming_response.py +4 -3
- letta/server/rest_api/utils.py +75 -18
- letta/server/server.py +24 -35
- letta/services/agent_manager.py +359 -45
- letta/services/agent_serialization_manager.py +23 -3
- letta/services/archive_manager.py +72 -3
- letta/services/block_manager.py +1 -2
- letta/services/context_window_calculator/token_counter.py +11 -6
- letta/services/file_manager.py +1 -3
- letta/services/files_agents_manager.py +2 -4
- letta/services/group_manager.py +73 -12
- letta/services/helpers/agent_manager_helper.py +5 -5
- letta/services/identity_manager.py +8 -3
- letta/services/job_manager.py +2 -14
- letta/services/llm_batch_manager.py +1 -3
- letta/services/mcp/base_client.py +1 -2
- letta/services/mcp_manager.py +5 -6
- letta/services/message_manager.py +536 -15
- letta/services/organization_manager.py +1 -2
- letta/services/passage_manager.py +287 -12
- letta/services/provider_manager.py +1 -3
- letta/services/sandbox_config_manager.py +12 -7
- letta/services/source_manager.py +1 -2
- letta/services/step_manager.py +0 -1
- letta/services/summarizer/summarizer.py +4 -2
- letta/services/telemetry_manager.py +1 -3
- letta/services/tool_executor/builtin_tool_executor.py +136 -316
- letta/services/tool_executor/core_tool_executor.py +231 -74
- letta/services/tool_executor/files_tool_executor.py +2 -2
- letta/services/tool_executor/mcp_tool_executor.py +0 -1
- letta/services/tool_executor/multi_agent_tool_executor.py +2 -2
- letta/services/tool_executor/sandbox_tool_executor.py +0 -1
- letta/services/tool_executor/tool_execution_sandbox.py +2 -3
- letta/services/tool_manager.py +181 -64
- letta/services/tool_sandbox/modal_deployment_manager.py +2 -2
- letta/services/user_manager.py +1 -2
- letta/settings.py +5 -3
- letta/streaming_interface.py +3 -3
- letta/system.py +1 -1
- letta/utils.py +0 -1
- {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/METADATA +11 -7
- {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/RECORD +137 -135
- letta/llm_api/deepseek.py +0 -303
- {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.11.6.dev20250903104037.dist-info → letta_nightly-0.11.7.dev20250904045700.dist-info}/licenses/LICENSE +0 -0
letta/services/agent_manager.py
CHANGED
@@ -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
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
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
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
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(
|
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(
|
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
|
-
"""
|
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
|
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
|
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
|
-
|
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(
|
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
|
-
|
2791
|
-
|
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
|
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
|