letta-nightly 0.8.17.dev20250723104501__py3-none-any.whl → 0.9.0.dev20250724104456__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 +5 -3
- letta/agent.py +3 -2
- letta/agents/base_agent.py +4 -1
- letta/agents/voice_agent.py +1 -0
- letta/constants.py +4 -2
- letta/functions/schema_generator.py +2 -1
- letta/groups/dynamic_multi_agent.py +1 -0
- letta/helpers/converters.py +13 -5
- letta/helpers/json_helpers.py +6 -1
- letta/llm_api/anthropic.py +2 -2
- letta/llm_api/aws_bedrock.py +24 -94
- letta/llm_api/deepseek.py +1 -1
- letta/llm_api/google_ai_client.py +0 -38
- letta/llm_api/google_constants.py +6 -3
- letta/llm_api/helpers.py +1 -1
- letta/llm_api/llm_api_tools.py +4 -7
- letta/llm_api/mistral.py +12 -37
- letta/llm_api/openai.py +17 -17
- letta/llm_api/sample_response_jsons/aws_bedrock.json +38 -0
- letta/llm_api/sample_response_jsons/lmstudio_embedding_list.json +15 -0
- letta/llm_api/sample_response_jsons/lmstudio_model_list.json +15 -0
- letta/local_llm/constants.py +2 -23
- letta/local_llm/json_parser.py +11 -1
- letta/local_llm/llm_chat_completion_wrappers/airoboros.py +9 -9
- letta/local_llm/llm_chat_completion_wrappers/chatml.py +7 -8
- letta/local_llm/llm_chat_completion_wrappers/configurable_wrapper.py +6 -6
- letta/local_llm/llm_chat_completion_wrappers/dolphin.py +3 -3
- letta/local_llm/llm_chat_completion_wrappers/simple_summary_wrapper.py +1 -1
- letta/local_llm/ollama/api.py +2 -2
- letta/orm/__init__.py +1 -0
- letta/orm/agent.py +33 -2
- letta/orm/files_agents.py +13 -10
- letta/orm/mixins.py +8 -0
- letta/orm/prompt.py +13 -0
- letta/orm/sqlite_functions.py +61 -17
- letta/otel/db_pool_monitoring.py +13 -12
- letta/schemas/agent.py +69 -4
- letta/schemas/agent_file.py +2 -0
- letta/schemas/block.py +11 -0
- letta/schemas/embedding_config.py +15 -3
- letta/schemas/enums.py +2 -0
- letta/schemas/file.py +1 -1
- letta/schemas/folder.py +74 -0
- letta/schemas/memory.py +12 -6
- letta/schemas/prompt.py +9 -0
- letta/schemas/providers/__init__.py +47 -0
- letta/schemas/providers/anthropic.py +78 -0
- letta/schemas/providers/azure.py +80 -0
- letta/schemas/providers/base.py +201 -0
- letta/schemas/providers/bedrock.py +78 -0
- letta/schemas/providers/cerebras.py +79 -0
- letta/schemas/providers/cohere.py +18 -0
- letta/schemas/providers/deepseek.py +63 -0
- letta/schemas/providers/google_gemini.py +102 -0
- letta/schemas/providers/google_vertex.py +54 -0
- letta/schemas/providers/groq.py +35 -0
- letta/schemas/providers/letta.py +39 -0
- letta/schemas/providers/lmstudio.py +97 -0
- letta/schemas/providers/mistral.py +41 -0
- letta/schemas/providers/ollama.py +151 -0
- letta/schemas/providers/openai.py +241 -0
- letta/schemas/providers/together.py +85 -0
- letta/schemas/providers/vllm.py +57 -0
- letta/schemas/providers/xai.py +66 -0
- letta/server/db.py +0 -5
- letta/server/rest_api/app.py +4 -3
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +152 -4
- letta/server/rest_api/routers/v1/folders.py +490 -0
- letta/server/rest_api/routers/v1/providers.py +2 -2
- letta/server/rest_api/routers/v1/sources.py +21 -26
- letta/server/rest_api/routers/v1/tools.py +90 -15
- letta/server/server.py +50 -95
- letta/services/agent_manager.py +420 -81
- letta/services/agent_serialization_manager.py +707 -0
- letta/services/block_manager.py +132 -11
- letta/services/file_manager.py +104 -29
- letta/services/file_processor/embedder/pinecone_embedder.py +8 -2
- letta/services/file_processor/file_processor.py +75 -24
- letta/services/file_processor/parser/markitdown_parser.py +95 -0
- letta/services/files_agents_manager.py +57 -17
- letta/services/group_manager.py +7 -0
- letta/services/helpers/agent_manager_helper.py +25 -15
- letta/services/provider_manager.py +2 -2
- letta/services/source_manager.py +35 -16
- letta/services/tool_executor/files_tool_executor.py +12 -5
- letta/services/tool_manager.py +12 -0
- letta/services/tool_sandbox/e2b_sandbox.py +52 -48
- letta/settings.py +9 -6
- letta/streaming_utils.py +2 -1
- letta/utils.py +34 -1
- {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724104456.dist-info}/METADATA +9 -8
- {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724104456.dist-info}/RECORD +96 -68
- {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724104456.dist-info}/LICENSE +0 -0
- {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724104456.dist-info}/WHEEL +0 -0
- {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724104456.dist-info}/entry_points.txt +0 -0
letta/services/agent_manager.py
CHANGED
@@ -46,6 +46,7 @@ from letta.schemas.block import Block as PydanticBlock
|
|
46
46
|
from letta.schemas.block import BlockUpdate
|
47
47
|
from letta.schemas.embedding_config import EmbeddingConfig
|
48
48
|
from letta.schemas.enums import ProviderType
|
49
|
+
from letta.schemas.file import FileMetadata as PydanticFileMetadata
|
49
50
|
from letta.schemas.group import Group as PydanticGroup
|
50
51
|
from letta.schemas.group import ManagerType
|
51
52
|
from letta.schemas.memory import ContextWindowOverview, Memory
|
@@ -65,6 +66,7 @@ from letta.server.db import db_registry
|
|
65
66
|
from letta.services.block_manager import BlockManager
|
66
67
|
from letta.services.context_window_calculator.context_window_calculator import ContextWindowCalculator
|
67
68
|
from letta.services.context_window_calculator.token_counter import AnthropicTokenCounter, TiktokenCounter
|
69
|
+
from letta.services.file_processor.chunker.line_chunker import LineChunker
|
68
70
|
from letta.services.files_agents_manager import FileAgentManager
|
69
71
|
from letta.services.helpers.agent_manager_helper import (
|
70
72
|
_apply_filters,
|
@@ -93,7 +95,7 @@ from letta.services.passage_manager import PassageManager
|
|
93
95
|
from letta.services.source_manager import SourceManager
|
94
96
|
from letta.services.tool_manager import ToolManager
|
95
97
|
from letta.settings import DatabaseChoice, settings
|
96
|
-
from letta.utils import enforce_types, united_diff
|
98
|
+
from letta.utils import calculate_file_defaults_based_on_context_window, enforce_types, united_diff
|
97
99
|
|
98
100
|
logger = get_logger(__name__)
|
99
101
|
|
@@ -110,32 +112,6 @@ class AgentManager:
|
|
110
112
|
self.identity_manager = IdentityManager()
|
111
113
|
self.file_agent_manager = FileAgentManager()
|
112
114
|
|
113
|
-
@trace_method
|
114
|
-
async def _validate_agent_exists_async(self, session, agent_id: str, actor: PydanticUser) -> None:
|
115
|
-
"""
|
116
|
-
Validate that an agent exists and user has access to it using raw SQL for efficiency.
|
117
|
-
|
118
|
-
Args:
|
119
|
-
session: Database session
|
120
|
-
agent_id: ID of the agent to validate
|
121
|
-
actor: User performing the action
|
122
|
-
|
123
|
-
Raises:
|
124
|
-
NoResultFound: If agent doesn't exist or user doesn't have access
|
125
|
-
"""
|
126
|
-
agent_check_query = sa.text(
|
127
|
-
"""
|
128
|
-
SELECT 1 FROM agents
|
129
|
-
WHERE id = :agent_id
|
130
|
-
AND organization_id = :org_id
|
131
|
-
AND is_deleted = false
|
132
|
-
"""
|
133
|
-
)
|
134
|
-
agent_exists = await session.execute(agent_check_query, {"agent_id": agent_id, "org_id": actor.organization_id})
|
135
|
-
|
136
|
-
if not agent_exists.fetchone():
|
137
|
-
raise NoResultFound(f"Agent with ID {agent_id} not found")
|
138
|
-
|
139
115
|
@staticmethod
|
140
116
|
def _resolve_tools(session, names: Set[str], ids: Set[str], org_id: str) -> Tuple[Dict[str, str], Dict[str, str]]:
|
141
117
|
"""
|
@@ -385,6 +361,8 @@ class AgentManager:
|
|
385
361
|
created_by_id=actor.id,
|
386
362
|
last_updated_by_id=actor.id,
|
387
363
|
timezone=agent_create.timezone,
|
364
|
+
max_files_open=agent_create.max_files_open,
|
365
|
+
per_file_view_window_char_limit=agent_create.per_file_view_window_char_limit,
|
388
366
|
)
|
389
367
|
|
390
368
|
if _test_only_force_id:
|
@@ -574,6 +552,8 @@ class AgentManager:
|
|
574
552
|
created_by_id=actor.id,
|
575
553
|
last_updated_by_id=actor.id,
|
576
554
|
timezone=agent_create.timezone if agent_create.timezone else DEFAULT_TIMEZONE,
|
555
|
+
max_files_open=agent_create.max_files_open,
|
556
|
+
per_file_view_window_char_limit=agent_create.per_file_view_window_char_limit,
|
577
557
|
)
|
578
558
|
|
579
559
|
if _test_only_force_id:
|
@@ -737,6 +717,9 @@ class AgentManager:
|
|
737
717
|
"response_format": agent_update.response_format,
|
738
718
|
"last_run_completion": agent_update.last_run_completion,
|
739
719
|
"last_run_duration_ms": agent_update.last_run_duration_ms,
|
720
|
+
"max_files_open": agent_update.max_files_open,
|
721
|
+
"per_file_view_window_char_limit": agent_update.per_file_view_window_char_limit,
|
722
|
+
"timezone": agent_update.timezone,
|
740
723
|
}
|
741
724
|
for col, val in scalar_updates.items():
|
742
725
|
if val is not None:
|
@@ -860,6 +843,8 @@ class AgentManager:
|
|
860
843
|
"last_run_completion": agent_update.last_run_completion,
|
861
844
|
"last_run_duration_ms": agent_update.last_run_duration_ms,
|
862
845
|
"timezone": agent_update.timezone,
|
846
|
+
"max_files_open": agent_update.max_files_open,
|
847
|
+
"per_file_view_window_char_limit": agent_update.per_file_view_window_char_limit,
|
863
848
|
}
|
864
849
|
for col, val in scalar_updates.items():
|
865
850
|
if val is not None:
|
@@ -1602,6 +1587,7 @@ class AgentManager:
|
|
1602
1587
|
previous_message_count=num_messages - len(agent_state.message_ids),
|
1603
1588
|
archival_memory_size=num_archival_memories,
|
1604
1589
|
sources=agent_state.sources,
|
1590
|
+
max_files_open=agent_state.max_files_open,
|
1605
1591
|
)
|
1606
1592
|
|
1607
1593
|
diff = united_diff(curr_system_message_openai["content"], new_system_message_str)
|
@@ -1659,7 +1645,9 @@ class AgentManager:
|
|
1659
1645
|
# note: we only update the system prompt if the core memory is changed
|
1660
1646
|
# this means that the archival/recall memory statistics may be someout out of date
|
1661
1647
|
curr_memory_str = agent_state.memory.compile(
|
1662
|
-
sources=agent_state.sources,
|
1648
|
+
sources=agent_state.sources,
|
1649
|
+
tool_usage_rules=tool_rules_solver.compile_tool_rule_prompts(),
|
1650
|
+
max_files_open=agent_state.max_files_open,
|
1663
1651
|
)
|
1664
1652
|
if curr_memory_str in curr_system_message_openai["content"] and not force:
|
1665
1653
|
# NOTE: could this cause issues if a block is removed? (substring match would still work)
|
@@ -1687,6 +1675,7 @@ class AgentManager:
|
|
1687
1675
|
archival_memory_size=num_archival_memories,
|
1688
1676
|
tool_rules_solver=tool_rules_solver,
|
1689
1677
|
sources=agent_state.sources,
|
1678
|
+
max_files_open=agent_state.max_files_open,
|
1690
1679
|
)
|
1691
1680
|
|
1692
1681
|
diff = united_diff(curr_system_message_openai["content"], new_system_message_str)
|
@@ -1846,7 +1835,11 @@ class AgentManager:
|
|
1846
1835
|
system_message = await self.message_manager.get_message_by_id_async(message_id=agent_state.message_ids[0], actor=actor)
|
1847
1836
|
temp_tool_rules_solver = ToolRulesSolver(agent_state.tool_rules)
|
1848
1837
|
if (
|
1849
|
-
new_memory.compile(
|
1838
|
+
new_memory.compile(
|
1839
|
+
sources=agent_state.sources,
|
1840
|
+
tool_usage_rules=temp_tool_rules_solver.compile_tool_rule_prompts(),
|
1841
|
+
max_files_open=agent_state.max_files_open,
|
1842
|
+
)
|
1850
1843
|
not in system_message.content[0].text
|
1851
1844
|
):
|
1852
1845
|
# update the blocks (LRW) in the DB
|
@@ -1890,7 +1883,10 @@ class AgentManager:
|
|
1890
1883
|
|
1891
1884
|
if file_block_names:
|
1892
1885
|
file_blocks = await self.file_agent_manager.get_all_file_blocks_by_name(
|
1893
|
-
file_names=file_block_names,
|
1886
|
+
file_names=file_block_names,
|
1887
|
+
agent_id=agent_state.id,
|
1888
|
+
actor=actor,
|
1889
|
+
per_file_view_window_char_limit=agent_state.per_file_view_window_char_limit,
|
1894
1890
|
)
|
1895
1891
|
agent_state.memory.file_blocks = [b for b in file_blocks if b is not None]
|
1896
1892
|
|
@@ -1899,7 +1895,12 @@ class AgentManager:
|
|
1899
1895
|
@enforce_types
|
1900
1896
|
@trace_method
|
1901
1897
|
async def refresh_file_blocks(self, agent_state: PydanticAgentState, actor: PydanticUser) -> PydanticAgentState:
|
1902
|
-
file_blocks = await self.file_agent_manager.list_files_for_agent(
|
1898
|
+
file_blocks = await self.file_agent_manager.list_files_for_agent(
|
1899
|
+
agent_id=agent_state.id,
|
1900
|
+
per_file_view_window_char_limit=agent_state.per_file_view_window_char_limit,
|
1901
|
+
actor=actor,
|
1902
|
+
return_as_blocks=True,
|
1903
|
+
)
|
1903
1904
|
agent_state.memory.file_blocks = [b for b in file_blocks if b is not None]
|
1904
1905
|
return agent_state
|
1905
1906
|
|
@@ -2593,7 +2594,7 @@ class AgentManager:
|
|
2593
2594
|
|
2594
2595
|
@enforce_types
|
2595
2596
|
@trace_method
|
2596
|
-
async def attach_tool_async(self, agent_id: str, tool_id: str, actor: PydanticUser) ->
|
2597
|
+
async def attach_tool_async(self, agent_id: str, tool_id: str, actor: PydanticUser) -> None:
|
2597
2598
|
"""
|
2598
2599
|
Attaches a tool to an agent.
|
2599
2600
|
|
@@ -2610,22 +2611,112 @@ class AgentManager:
|
|
2610
2611
|
"""
|
2611
2612
|
async with db_registry.async_session() as session:
|
2612
2613
|
# Verify the agent exists and user has permission to access it
|
2613
|
-
|
2614
|
+
await validate_agent_exists_async(session, agent_id, actor)
|
2614
2615
|
|
2615
|
-
#
|
2616
|
-
|
2617
|
-
|
2618
|
-
|
2619
|
-
relationship_name="tools",
|
2620
|
-
model_class=ToolModel,
|
2621
|
-
item_ids=[tool_id],
|
2622
|
-
allow_partial=False, # Ensure the tool exists
|
2623
|
-
replace=False, # Extend the existing tools
|
2616
|
+
# verify tool exists and belongs to organization in a single query with the insert
|
2617
|
+
# first, check if tool exists with correct organization
|
2618
|
+
tool_check_query = select(func.count(ToolModel.id)).where(
|
2619
|
+
ToolModel.id == tool_id, ToolModel.organization_id == actor.organization_id
|
2624
2620
|
)
|
2621
|
+
tool_result = await session.execute(tool_check_query)
|
2622
|
+
if tool_result.scalar() == 0:
|
2623
|
+
raise NoResultFound(f"Tool with id={tool_id} not found in organization={actor.organization_id}")
|
2624
|
+
|
2625
|
+
# use postgresql on conflict or mysql on duplicate key update for atomic operation
|
2626
|
+
if settings.letta_pg_uri_no_default:
|
2627
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
2628
|
+
|
2629
|
+
insert_stmt = pg_insert(ToolsAgents).values(agent_id=agent_id, tool_id=tool_id)
|
2630
|
+
# on conflict do nothing - silently ignore if already exists
|
2631
|
+
insert_stmt = insert_stmt.on_conflict_do_nothing(index_elements=["agent_id", "tool_id"])
|
2632
|
+
result = await session.execute(insert_stmt)
|
2633
|
+
if result.rowcount == 0:
|
2634
|
+
logger.info(f"Tool id={tool_id} is already attached to agent id={agent_id}")
|
2635
|
+
else:
|
2636
|
+
# for sqlite/mysql, check then insert
|
2637
|
+
existing_query = (
|
2638
|
+
select(func.count()).select_from(ToolsAgents).where(ToolsAgents.agent_id == agent_id, ToolsAgents.tool_id == tool_id)
|
2639
|
+
)
|
2640
|
+
existing_result = await session.execute(existing_query)
|
2641
|
+
if existing_result.scalar() == 0:
|
2642
|
+
insert_stmt = insert(ToolsAgents).values(agent_id=agent_id, tool_id=tool_id)
|
2643
|
+
await session.execute(insert_stmt)
|
2644
|
+
else:
|
2645
|
+
logger.info(f"Tool id={tool_id} is already attached to agent id={agent_id}")
|
2625
2646
|
|
2626
|
-
|
2627
|
-
|
2628
|
-
|
2647
|
+
await session.commit()
|
2648
|
+
|
2649
|
+
@enforce_types
|
2650
|
+
@trace_method
|
2651
|
+
async def bulk_attach_tools_async(self, agent_id: str, tool_ids: List[str], actor: PydanticUser) -> None:
|
2652
|
+
"""
|
2653
|
+
Efficiently attaches multiple tools to an agent in a single operation.
|
2654
|
+
|
2655
|
+
Args:
|
2656
|
+
agent_id: ID of the agent to attach the tools to.
|
2657
|
+
tool_ids: List of tool IDs to attach.
|
2658
|
+
actor: User performing the action.
|
2659
|
+
|
2660
|
+
Raises:
|
2661
|
+
NoResultFound: If the agent or any tool is not found.
|
2662
|
+
"""
|
2663
|
+
if not tool_ids:
|
2664
|
+
# no tools to attach, nothing to do
|
2665
|
+
return
|
2666
|
+
|
2667
|
+
async with db_registry.async_session() as session:
|
2668
|
+
# Verify the agent exists and user has permission to access it
|
2669
|
+
await validate_agent_exists_async(session, agent_id, actor)
|
2670
|
+
|
2671
|
+
# verify all tools exist and belong to organization in a single query
|
2672
|
+
tool_check_query = select(func.count(ToolModel.id)).where(
|
2673
|
+
ToolModel.id.in_(tool_ids), ToolModel.organization_id == actor.organization_id
|
2674
|
+
)
|
2675
|
+
tool_result = await session.execute(tool_check_query)
|
2676
|
+
found_count = tool_result.scalar()
|
2677
|
+
|
2678
|
+
if found_count != len(tool_ids):
|
2679
|
+
# find which tools are missing for better error message
|
2680
|
+
existing_query = select(ToolModel.id).where(ToolModel.id.in_(tool_ids), ToolModel.organization_id == actor.organization_id)
|
2681
|
+
existing_result = await session.execute(existing_query)
|
2682
|
+
existing_ids = {row[0] for row in existing_result}
|
2683
|
+
missing_ids = set(tool_ids) - existing_ids
|
2684
|
+
raise NoResultFound(f"Tools with ids={missing_ids} not found in organization={actor.organization_id}")
|
2685
|
+
|
2686
|
+
if settings.letta_pg_uri_no_default:
|
2687
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
2688
|
+
|
2689
|
+
# prepare bulk values
|
2690
|
+
values = [{"agent_id": agent_id, "tool_id": tool_id} for tool_id in tool_ids]
|
2691
|
+
|
2692
|
+
# bulk insert with on conflict do nothing
|
2693
|
+
insert_stmt = pg_insert(ToolsAgents).values(values)
|
2694
|
+
insert_stmt = insert_stmt.on_conflict_do_nothing(index_elements=["agent_id", "tool_id"])
|
2695
|
+
result = await session.execute(insert_stmt)
|
2696
|
+
logger.info(
|
2697
|
+
f"Attached {result.rowcount} new tools to agent {agent_id} (skipped {len(tool_ids) - result.rowcount} already attached)"
|
2698
|
+
)
|
2699
|
+
else:
|
2700
|
+
# for sqlite/mysql, first check which tools are already attached
|
2701
|
+
existing_query = select(ToolsAgents.tool_id).where(ToolsAgents.agent_id == agent_id, ToolsAgents.tool_id.in_(tool_ids))
|
2702
|
+
existing_result = await session.execute(existing_query)
|
2703
|
+
already_attached = {row[0] for row in existing_result}
|
2704
|
+
|
2705
|
+
# only insert tools that aren't already attached
|
2706
|
+
new_tool_ids = [tid for tid in tool_ids if tid not in already_attached]
|
2707
|
+
|
2708
|
+
if new_tool_ids:
|
2709
|
+
# bulk insert new attachments
|
2710
|
+
values = [{"agent_id": agent_id, "tool_id": tool_id} for tool_id in new_tool_ids]
|
2711
|
+
insert_stmt = insert(ToolsAgents).values(values)
|
2712
|
+
await session.execute(insert_stmt)
|
2713
|
+
logger.info(
|
2714
|
+
f"Attached {len(new_tool_ids)} new tools to agent {agent_id} (skipped {len(already_attached)} already attached)"
|
2715
|
+
)
|
2716
|
+
else:
|
2717
|
+
logger.info(f"All {len(tool_ids)} tools already attached to agent {agent_id}")
|
2718
|
+
|
2719
|
+
await session.commit()
|
2629
2720
|
|
2630
2721
|
@enforce_types
|
2631
2722
|
@trace_method
|
@@ -2634,7 +2725,7 @@ class AgentManager:
|
|
2634
2725
|
Attaches missing core file tools to an agent.
|
2635
2726
|
|
2636
2727
|
Args:
|
2637
|
-
|
2728
|
+
agent_state: The current agent state with tools already loaded.
|
2638
2729
|
actor: User performing the action.
|
2639
2730
|
|
2640
2731
|
Raises:
|
@@ -2643,21 +2734,50 @@ class AgentManager:
|
|
2643
2734
|
Returns:
|
2644
2735
|
PydanticAgentState: The updated agent state.
|
2645
2736
|
"""
|
2646
|
-
#
|
2647
|
-
|
2648
|
-
|
2737
|
+
# get current file tools attached to the agent
|
2738
|
+
attached_file_tool_names = {tool.name for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE}
|
2739
|
+
|
2740
|
+
# determine which file tools are missing
|
2741
|
+
missing_tool_names = set(FILES_TOOLS) - attached_file_tool_names
|
2742
|
+
|
2743
|
+
if not missing_tool_names:
|
2744
|
+
# agent already has all file tools
|
2745
|
+
return agent_state
|
2649
2746
|
|
2650
|
-
for
|
2651
|
-
|
2747
|
+
# get full tool objects for all missing file tools in one query
|
2748
|
+
async with db_registry.async_session() as session:
|
2749
|
+
query = select(ToolModel).where(
|
2750
|
+
ToolModel.name.in_(missing_tool_names),
|
2751
|
+
ToolModel.organization_id == actor.organization_id,
|
2752
|
+
ToolModel.tool_type == ToolType.LETTA_FILES_CORE,
|
2753
|
+
)
|
2754
|
+
result = await session.execute(query)
|
2755
|
+
found_tool_models = result.scalars().all()
|
2652
2756
|
|
2653
|
-
|
2654
|
-
|
2655
|
-
|
2757
|
+
if not found_tool_models:
|
2758
|
+
logger.warning(f"No file tools found for organization {actor.organization_id}. Expected tools: {missing_tool_names}")
|
2759
|
+
return agent_state
|
2656
2760
|
|
2657
|
-
|
2658
|
-
|
2761
|
+
# convert to pydantic tools
|
2762
|
+
found_tools = [tool.to_pydantic() for tool in found_tool_models]
|
2763
|
+
found_tool_names = {tool.name for tool in found_tools}
|
2659
2764
|
|
2660
|
-
|
2765
|
+
# log if any expected tools weren't found
|
2766
|
+
still_missing = missing_tool_names - found_tool_names
|
2767
|
+
if still_missing:
|
2768
|
+
logger.warning(f"File tools {still_missing} not found in organization {actor.organization_id}")
|
2769
|
+
|
2770
|
+
# extract tool IDs for bulk attach
|
2771
|
+
tool_ids_to_attach = [tool.id for tool in found_tools]
|
2772
|
+
|
2773
|
+
# bulk attach all found file tools
|
2774
|
+
await self.bulk_attach_tools_async(agent_id=agent_state.id, tool_ids=tool_ids_to_attach, actor=actor)
|
2775
|
+
|
2776
|
+
# create a shallow copy with updated tools list to avoid modifying input
|
2777
|
+
agent_state_dict = agent_state.model_dump()
|
2778
|
+
agent_state_dict["tools"] = agent_state.tools + found_tools
|
2779
|
+
|
2780
|
+
return PydanticAgentState(**agent_state_dict)
|
2661
2781
|
|
2662
2782
|
@enforce_types
|
2663
2783
|
@trace_method
|
@@ -2666,25 +2786,30 @@ class AgentManager:
|
|
2666
2786
|
Detach all core file tools from an agent.
|
2667
2787
|
|
2668
2788
|
Args:
|
2669
|
-
|
2789
|
+
agent_state: The current agent state with tools already loaded.
|
2670
2790
|
actor: User performing the action.
|
2671
2791
|
|
2672
2792
|
Raises:
|
2673
|
-
NoResultFound: If the agent
|
2793
|
+
NoResultFound: If the agent is not found.
|
2674
2794
|
|
2675
2795
|
Returns:
|
2676
2796
|
PydanticAgentState: The updated agent state.
|
2677
2797
|
"""
|
2678
|
-
#
|
2679
|
-
|
2798
|
+
# extract file tool IDs directly from agent_state.tools
|
2799
|
+
file_tool_ids = [tool.id for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE]
|
2680
2800
|
|
2681
|
-
|
2682
|
-
|
2801
|
+
if not file_tool_ids:
|
2802
|
+
# no file tools to detach
|
2803
|
+
return agent_state
|
2683
2804
|
|
2684
|
-
|
2685
|
-
|
2805
|
+
# bulk detach all file tools in one operation
|
2806
|
+
await self.bulk_detach_tools_async(agent_id=agent_state.id, tool_ids=file_tool_ids, actor=actor)
|
2686
2807
|
|
2687
|
-
|
2808
|
+
# create a shallow copy with updated tools list to avoid modifying input
|
2809
|
+
agent_state_dict = agent_state.model_dump()
|
2810
|
+
agent_state_dict["tools"] = [tool for tool in agent_state.tools if tool.tool_type != ToolType.LETTA_FILES_CORE]
|
2811
|
+
|
2812
|
+
return PydanticAgentState(**agent_state_dict)
|
2688
2813
|
|
2689
2814
|
@enforce_types
|
2690
2815
|
@trace_method
|
@@ -2722,7 +2847,7 @@ class AgentManager:
|
|
2722
2847
|
|
2723
2848
|
@enforce_types
|
2724
2849
|
@trace_method
|
2725
|
-
async def detach_tool_async(self, agent_id: str, tool_id: str, actor: PydanticUser) ->
|
2850
|
+
async def detach_tool_async(self, agent_id: str, tool_id: str, actor: PydanticUser) -> None:
|
2726
2851
|
"""
|
2727
2852
|
Detaches a tool from an agent.
|
2728
2853
|
|
@@ -2732,27 +2857,58 @@ class AgentManager:
|
|
2732
2857
|
actor: User performing the action.
|
2733
2858
|
|
2734
2859
|
Raises:
|
2735
|
-
NoResultFound: If the agent
|
2736
|
-
|
2737
|
-
Returns:
|
2738
|
-
PydanticAgentState: The updated agent state.
|
2860
|
+
NoResultFound: If the agent is not found.
|
2739
2861
|
"""
|
2740
2862
|
async with db_registry.async_session() as session:
|
2741
2863
|
# Verify the agent exists and user has permission to access it
|
2742
|
-
|
2864
|
+
await validate_agent_exists_async(session, agent_id, actor)
|
2743
2865
|
|
2744
|
-
#
|
2745
|
-
|
2866
|
+
# Delete the association directly - if it doesn't exist, rowcount will be 0
|
2867
|
+
delete_query = delete(ToolsAgents).where(ToolsAgents.agent_id == agent_id, ToolsAgents.tool_id == tool_id)
|
2868
|
+
result = await session.execute(delete_query)
|
2746
2869
|
|
2747
|
-
if
|
2870
|
+
if result.rowcount == 0:
|
2748
2871
|
logger.warning(f"Attempted to remove unattached tool id={tool_id} from agent id={agent_id} by actor={actor}")
|
2872
|
+
else:
|
2873
|
+
logger.debug(f"Detached tool id={tool_id} from agent id={agent_id}")
|
2749
2874
|
|
2750
|
-
|
2751
|
-
agent.tools = remaining_tools
|
2875
|
+
await session.commit()
|
2752
2876
|
|
2753
|
-
|
2754
|
-
|
2755
|
-
|
2877
|
+
@enforce_types
|
2878
|
+
@trace_method
|
2879
|
+
async def bulk_detach_tools_async(self, agent_id: str, tool_ids: List[str], actor: PydanticUser) -> None:
|
2880
|
+
"""
|
2881
|
+
Efficiently detaches multiple tools from an agent in a single operation.
|
2882
|
+
|
2883
|
+
Args:
|
2884
|
+
agent_id: ID of the agent to detach the tools from.
|
2885
|
+
tool_ids: List of tool IDs to detach.
|
2886
|
+
actor: User performing the action.
|
2887
|
+
|
2888
|
+
Raises:
|
2889
|
+
NoResultFound: If the agent is not found.
|
2890
|
+
"""
|
2891
|
+
if not tool_ids:
|
2892
|
+
# no tools to detach, nothing to do
|
2893
|
+
return
|
2894
|
+
|
2895
|
+
async with db_registry.async_session() as session:
|
2896
|
+
# Verify the agent exists and user has permission to access it
|
2897
|
+
await validate_agent_exists_async(session, agent_id, actor)
|
2898
|
+
|
2899
|
+
# Delete all associations in a single query
|
2900
|
+
delete_query = delete(ToolsAgents).where(ToolsAgents.agent_id == agent_id, ToolsAgents.tool_id.in_(tool_ids))
|
2901
|
+
result = await session.execute(delete_query)
|
2902
|
+
|
2903
|
+
detached_count = result.rowcount
|
2904
|
+
if detached_count == 0:
|
2905
|
+
logger.warning(f"No tools from list {tool_ids} were attached to agent id={agent_id}")
|
2906
|
+
elif detached_count < len(tool_ids):
|
2907
|
+
logger.info(f"Detached {detached_count} tools from agent {agent_id} ({len(tool_ids) - detached_count} were not attached)")
|
2908
|
+
else:
|
2909
|
+
logger.info(f"Detached all {detached_count} tools from agent {agent_id}")
|
2910
|
+
|
2911
|
+
await session.commit()
|
2756
2912
|
|
2757
2913
|
@enforce_types
|
2758
2914
|
@trace_method
|
@@ -2800,6 +2956,83 @@ class AgentManager:
|
|
2800
2956
|
tools = result.scalars().all()
|
2801
2957
|
return [tool.to_pydantic() for tool in tools]
|
2802
2958
|
|
2959
|
+
# ======================================================================================================================
|
2960
|
+
# File Management
|
2961
|
+
# ======================================================================================================================
|
2962
|
+
async def insert_file_into_context_windows(
|
2963
|
+
self,
|
2964
|
+
source_id: str,
|
2965
|
+
file_metadata_with_content: PydanticFileMetadata,
|
2966
|
+
actor: PydanticUser,
|
2967
|
+
agent_states: Optional[List[PydanticAgentState]] = None,
|
2968
|
+
) -> List[PydanticAgentState]:
|
2969
|
+
"""
|
2970
|
+
Insert the uploaded document into the context window of all agents
|
2971
|
+
attached to the given source.
|
2972
|
+
"""
|
2973
|
+
agent_states = agent_states or await self.source_manager.list_attached_agents(source_id=source_id, actor=actor)
|
2974
|
+
|
2975
|
+
# Return early
|
2976
|
+
if not agent_states:
|
2977
|
+
return []
|
2978
|
+
|
2979
|
+
logger.info(f"Inserting document into context window for source: {source_id}")
|
2980
|
+
logger.info(f"Attached agents: {[a.id for a in agent_states]}")
|
2981
|
+
|
2982
|
+
# Generate visible content for the file
|
2983
|
+
line_chunker = LineChunker()
|
2984
|
+
content_lines = line_chunker.chunk_text(file_metadata=file_metadata_with_content)
|
2985
|
+
visible_content = "\n".join(content_lines)
|
2986
|
+
visible_content_map = {file_metadata_with_content.file_name: visible_content}
|
2987
|
+
|
2988
|
+
# Attach file to each agent using bulk method (one file per agent, but atomic per agent)
|
2989
|
+
all_closed_files = await asyncio.gather(
|
2990
|
+
*(
|
2991
|
+
self.file_agent_manager.attach_files_bulk(
|
2992
|
+
agent_id=agent_state.id,
|
2993
|
+
files_metadata=[file_metadata_with_content],
|
2994
|
+
visible_content_map=visible_content_map,
|
2995
|
+
actor=actor,
|
2996
|
+
max_files_open=agent_state.max_files_open,
|
2997
|
+
)
|
2998
|
+
for agent_state in agent_states
|
2999
|
+
)
|
3000
|
+
)
|
3001
|
+
# Flatten and log if any files were closed
|
3002
|
+
closed_files = [file for closed_list in all_closed_files for file in closed_list]
|
3003
|
+
if closed_files:
|
3004
|
+
logger.info(f"LRU eviction closed {len(closed_files)} files during bulk attach: {closed_files}")
|
3005
|
+
|
3006
|
+
return agent_states
|
3007
|
+
|
3008
|
+
async def insert_files_into_context_window(
|
3009
|
+
self, agent_state: PydanticAgentState, file_metadata_with_content: List[PydanticFileMetadata], actor: PydanticUser
|
3010
|
+
) -> None:
|
3011
|
+
"""
|
3012
|
+
Insert the uploaded documents into the context window of an agent
|
3013
|
+
attached to the given source.
|
3014
|
+
"""
|
3015
|
+
logger.info(f"Inserting {len(file_metadata_with_content)} documents into context window for agent_state: {agent_state.id}")
|
3016
|
+
|
3017
|
+
# Generate visible content for each file
|
3018
|
+
line_chunker = LineChunker()
|
3019
|
+
visible_content_map = {}
|
3020
|
+
for file_metadata in file_metadata_with_content:
|
3021
|
+
content_lines = line_chunker.chunk_text(file_metadata=file_metadata)
|
3022
|
+
visible_content_map[file_metadata.file_name] = "\n".join(content_lines)
|
3023
|
+
|
3024
|
+
# Use bulk attach to avoid race conditions and duplicate LRU eviction decisions
|
3025
|
+
closed_files = await self.file_agent_manager.attach_files_bulk(
|
3026
|
+
agent_id=agent_state.id,
|
3027
|
+
files_metadata=file_metadata_with_content,
|
3028
|
+
visible_content_map=visible_content_map,
|
3029
|
+
actor=actor,
|
3030
|
+
max_files_open=agent_state.max_files_open,
|
3031
|
+
)
|
3032
|
+
|
3033
|
+
if closed_files:
|
3034
|
+
logger.info(f"LRU eviction closed {len(closed_files)} files during bulk insert: {closed_files}")
|
3035
|
+
|
2803
3036
|
# ======================================================================================================================
|
2804
3037
|
# Tag Management
|
2805
3038
|
# ======================================================================================================================
|
@@ -2888,6 +3121,112 @@ class AgentManager:
|
|
2888
3121
|
results = [row[0] for row in result.all()]
|
2889
3122
|
return results
|
2890
3123
|
|
3124
|
+
@enforce_types
|
3125
|
+
@trace_method
|
3126
|
+
async def get_agent_files_config_async(self, agent_id: str, actor: PydanticUser) -> Tuple[int, int]:
|
3127
|
+
"""Get per_file_view_window_char_limit and max_files_open for an agent.
|
3128
|
+
|
3129
|
+
This is a performant query that only fetches the specific fields needed.
|
3130
|
+
|
3131
|
+
Args:
|
3132
|
+
agent_id: The ID of the agent
|
3133
|
+
actor: The user making the request
|
3134
|
+
|
3135
|
+
Returns:
|
3136
|
+
Tuple of per_file_view_window_char_limit, max_files_open values
|
3137
|
+
"""
|
3138
|
+
async with db_registry.async_session() as session:
|
3139
|
+
result = await session.execute(
|
3140
|
+
select(AgentModel.per_file_view_window_char_limit, AgentModel.max_files_open)
|
3141
|
+
.where(AgentModel.id == agent_id)
|
3142
|
+
.where(AgentModel.organization_id == actor.organization_id)
|
3143
|
+
.where(AgentModel.is_deleted == False)
|
3144
|
+
)
|
3145
|
+
row = result.one_or_none()
|
3146
|
+
|
3147
|
+
if row is None:
|
3148
|
+
raise ValueError(f"Agent {agent_id} not found")
|
3149
|
+
|
3150
|
+
per_file_limit, max_files = row[0], row[1]
|
3151
|
+
|
3152
|
+
# Handle None values by calculating defaults based on context window
|
3153
|
+
if per_file_limit is None or max_files is None:
|
3154
|
+
# Get the agent's model context window to calculate appropriate defaults
|
3155
|
+
model_result = await session.execute(
|
3156
|
+
select(AgentModel.llm_config)
|
3157
|
+
.where(AgentModel.id == agent_id)
|
3158
|
+
.where(AgentModel.organization_id == actor.organization_id)
|
3159
|
+
.where(AgentModel.is_deleted == False)
|
3160
|
+
)
|
3161
|
+
model_row = model_result.one_or_none()
|
3162
|
+
context_window = model_row[0].context_window if model_row and model_row[0] else None
|
3163
|
+
|
3164
|
+
default_max_files, default_per_file_limit = calculate_file_defaults_based_on_context_window(context_window)
|
3165
|
+
|
3166
|
+
# Use calculated defaults for None values
|
3167
|
+
if per_file_limit is None:
|
3168
|
+
per_file_limit = default_per_file_limit
|
3169
|
+
if max_files is None:
|
3170
|
+
max_files = default_max_files
|
3171
|
+
|
3172
|
+
return per_file_limit, max_files
|
3173
|
+
|
3174
|
+
@enforce_types
|
3175
|
+
@trace_method
|
3176
|
+
async def get_agent_max_files_open_async(self, agent_id: str, actor: PydanticUser) -> int:
|
3177
|
+
"""Get max_files_open for an agent.
|
3178
|
+
|
3179
|
+
This is a performant query that only fetches the specific field needed.
|
3180
|
+
|
3181
|
+
Args:
|
3182
|
+
agent_id: The ID of the agent
|
3183
|
+
actor: The user making the request
|
3184
|
+
|
3185
|
+
Returns:
|
3186
|
+
max_files_open value
|
3187
|
+
"""
|
3188
|
+
async with db_registry.async_session() as session:
|
3189
|
+
result = await session.execute(
|
3190
|
+
select(AgentModel.max_files_open)
|
3191
|
+
.where(AgentModel.id == agent_id)
|
3192
|
+
.where(AgentModel.organization_id == actor.organization_id)
|
3193
|
+
.where(AgentModel.is_deleted == False)
|
3194
|
+
)
|
3195
|
+
row = result.scalar_one_or_none()
|
3196
|
+
|
3197
|
+
if row is None:
|
3198
|
+
raise ValueError(f"Agent {agent_id} not found")
|
3199
|
+
|
3200
|
+
return row
|
3201
|
+
|
3202
|
+
@enforce_types
|
3203
|
+
@trace_method
|
3204
|
+
async def get_agent_per_file_view_window_char_limit_async(self, agent_id: str, actor: PydanticUser) -> int:
|
3205
|
+
"""Get per_file_view_window_char_limit for an agent.
|
3206
|
+
|
3207
|
+
This is a performant query that only fetches the specific field needed.
|
3208
|
+
|
3209
|
+
Args:
|
3210
|
+
agent_id: The ID of the agent
|
3211
|
+
actor: The user making the request
|
3212
|
+
|
3213
|
+
Returns:
|
3214
|
+
per_file_view_window_char_limit value
|
3215
|
+
"""
|
3216
|
+
async with db_registry.async_session() as session:
|
3217
|
+
result = await session.execute(
|
3218
|
+
select(AgentModel.per_file_view_window_char_limit)
|
3219
|
+
.where(AgentModel.id == agent_id)
|
3220
|
+
.where(AgentModel.organization_id == actor.organization_id)
|
3221
|
+
.where(AgentModel.is_deleted == False)
|
3222
|
+
)
|
3223
|
+
row = result.scalar_one_or_none()
|
3224
|
+
|
3225
|
+
if row is None:
|
3226
|
+
raise ValueError(f"Agent {agent_id} not found")
|
3227
|
+
|
3228
|
+
return row
|
3229
|
+
|
2891
3230
|
@trace_method
|
2892
3231
|
async def get_context_window(self, agent_id: str, actor: PydanticUser) -> ContextWindowOverview:
|
2893
3232
|
agent_state, system_message, num_messages, num_archival_memories = await self.rebuild_system_prompt_async(
|
@@ -2895,7 +3234,7 @@ class AgentManager:
|
|
2895
3234
|
)
|
2896
3235
|
calculator = ContextWindowCalculator()
|
2897
3236
|
|
2898
|
-
if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION"
|
3237
|
+
if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION" or agent_state.llm_config.model_endpoint_type == "anthropic":
|
2899
3238
|
anthropic_client = LLMClient.create(provider_type=ProviderType.anthropic, actor=actor)
|
2900
3239
|
model = agent_state.llm_config.model if agent_state.llm_config.model_endpoint_type == "anthropic" else None
|
2901
3240
|
|