letta-nightly 0.8.0.dev20250606195656__py3-none-any.whl → 0.8.3.dev20250607000559__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 (105) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +16 -12
  3. letta/agents/base_agent.py +1 -1
  4. letta/agents/helpers.py +13 -2
  5. letta/agents/letta_agent.py +72 -34
  6. letta/agents/letta_agent_batch.py +1 -2
  7. letta/agents/voice_agent.py +19 -13
  8. letta/agents/voice_sleeptime_agent.py +23 -6
  9. letta/constants.py +18 -0
  10. letta/data_sources/__init__.py +0 -0
  11. letta/data_sources/redis_client.py +282 -0
  12. letta/errors.py +0 -4
  13. letta/functions/function_sets/files.py +58 -0
  14. letta/functions/schema_generator.py +18 -1
  15. letta/groups/sleeptime_multi_agent_v2.py +13 -3
  16. letta/helpers/datetime_helpers.py +47 -3
  17. letta/helpers/decorators.py +69 -0
  18. letta/{services/helpers/noop_helper.py → helpers/singleton.py} +5 -0
  19. letta/interfaces/anthropic_streaming_interface.py +43 -24
  20. letta/interfaces/openai_streaming_interface.py +21 -19
  21. letta/llm_api/anthropic.py +1 -1
  22. letta/llm_api/anthropic_client.py +30 -16
  23. letta/llm_api/google_vertex_client.py +1 -1
  24. letta/llm_api/helpers.py +36 -30
  25. letta/llm_api/llm_api_tools.py +1 -1
  26. letta/llm_api/llm_client_base.py +29 -1
  27. letta/llm_api/openai.py +1 -1
  28. letta/llm_api/openai_client.py +6 -8
  29. letta/local_llm/chat_completion_proxy.py +1 -1
  30. letta/memory.py +1 -1
  31. letta/orm/enums.py +1 -0
  32. letta/orm/file.py +80 -3
  33. letta/orm/files_agents.py +13 -0
  34. letta/orm/passage.py +2 -0
  35. letta/orm/sqlalchemy_base.py +34 -11
  36. letta/otel/__init__.py +0 -0
  37. letta/otel/context.py +25 -0
  38. letta/otel/events.py +0 -0
  39. letta/otel/metric_registry.py +122 -0
  40. letta/otel/metrics.py +66 -0
  41. letta/otel/resource.py +26 -0
  42. letta/{tracing.py → otel/tracing.py} +55 -78
  43. letta/plugins/README.md +22 -0
  44. letta/plugins/__init__.py +0 -0
  45. letta/plugins/defaults.py +11 -0
  46. letta/plugins/plugins.py +72 -0
  47. letta/schemas/enums.py +8 -0
  48. letta/schemas/file.py +12 -0
  49. letta/schemas/letta_request.py +6 -0
  50. letta/schemas/passage.py +1 -0
  51. letta/schemas/tool.py +4 -0
  52. letta/server/db.py +7 -7
  53. letta/server/rest_api/app.py +8 -6
  54. letta/server/rest_api/routers/v1/agents.py +46 -37
  55. letta/server/rest_api/routers/v1/groups.py +3 -3
  56. letta/server/rest_api/routers/v1/sources.py +26 -3
  57. letta/server/rest_api/routers/v1/tools.py +7 -2
  58. letta/server/rest_api/utils.py +9 -6
  59. letta/server/server.py +25 -13
  60. letta/services/agent_manager.py +186 -194
  61. letta/services/block_manager.py +1 -1
  62. letta/services/context_window_calculator/context_window_calculator.py +1 -1
  63. letta/services/context_window_calculator/token_counter.py +3 -2
  64. letta/services/file_processor/chunker/line_chunker.py +34 -0
  65. letta/services/file_processor/file_processor.py +43 -12
  66. letta/services/file_processor/parser/mistral_parser.py +11 -1
  67. letta/services/files_agents_manager.py +96 -7
  68. letta/services/group_manager.py +6 -6
  69. letta/services/helpers/agent_manager_helper.py +404 -3
  70. letta/services/identity_manager.py +1 -1
  71. letta/services/job_manager.py +1 -1
  72. letta/services/llm_batch_manager.py +1 -1
  73. letta/services/mcp/stdio_client.py +5 -1
  74. letta/services/mcp_manager.py +4 -4
  75. letta/services/message_manager.py +1 -1
  76. letta/services/organization_manager.py +1 -1
  77. letta/services/passage_manager.py +604 -19
  78. letta/services/per_agent_lock_manager.py +1 -1
  79. letta/services/provider_manager.py +1 -1
  80. letta/services/sandbox_config_manager.py +1 -1
  81. letta/services/source_manager.py +178 -19
  82. letta/services/step_manager.py +2 -2
  83. letta/services/summarizer/summarizer.py +1 -1
  84. letta/services/telemetry_manager.py +1 -1
  85. letta/services/tool_executor/builtin_tool_executor.py +117 -0
  86. letta/services/tool_executor/composio_tool_executor.py +53 -0
  87. letta/services/tool_executor/core_tool_executor.py +474 -0
  88. letta/services/tool_executor/files_tool_executor.py +138 -0
  89. letta/services/tool_executor/mcp_tool_executor.py +45 -0
  90. letta/services/tool_executor/multi_agent_tool_executor.py +123 -0
  91. letta/services/tool_executor/tool_execution_manager.py +34 -14
  92. letta/services/tool_executor/tool_execution_sandbox.py +1 -1
  93. letta/services/tool_executor/tool_executor.py +3 -802
  94. letta/services/tool_executor/tool_executor_base.py +43 -0
  95. letta/services/tool_manager.py +55 -59
  96. letta/services/tool_sandbox/e2b_sandbox.py +1 -1
  97. letta/services/tool_sandbox/local_sandbox.py +6 -3
  98. letta/services/user_manager.py +6 -3
  99. letta/settings.py +23 -2
  100. letta/utils.py +7 -2
  101. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.3.dev20250607000559.dist-info}/METADATA +4 -2
  102. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.3.dev20250607000559.dist-info}/RECORD +105 -83
  103. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.3.dev20250607000559.dist-info}/LICENSE +0 -0
  104. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.3.dev20250607000559.dist-info}/WHEEL +0 -0
  105. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.3.dev20250607000559.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  import threading
2
2
  from collections import defaultdict
3
3
 
4
- from letta.tracing import trace_method
4
+ from letta.otel.tracing import trace_method
5
5
 
6
6
 
7
7
  class PerAgentLockManager:
@@ -1,12 +1,12 @@
1
1
  from typing import List, Optional, Union
2
2
 
3
3
  from letta.orm.provider import Provider as ProviderModel
4
+ from letta.otel.tracing import trace_method
4
5
  from letta.schemas.enums import ProviderCategory, ProviderType
5
6
  from letta.schemas.providers import Provider as PydanticProvider
6
7
  from letta.schemas.providers import ProviderCheck, ProviderCreate, ProviderUpdate
7
8
  from letta.schemas.user import User as PydanticUser
8
9
  from letta.server.db import db_registry
9
- from letta.tracing import trace_method
10
10
  from letta.utils import enforce_types
11
11
 
12
12
 
@@ -5,6 +5,7 @@ from letta.log import get_logger
5
5
  from letta.orm.errors import NoResultFound
6
6
  from letta.orm.sandbox_config import SandboxConfig as SandboxConfigModel
7
7
  from letta.orm.sandbox_config import SandboxEnvironmentVariable as SandboxEnvVarModel
8
+ from letta.otel.tracing import trace_method
8
9
  from letta.schemas.environment_variables import SandboxEnvironmentVariable as PydanticEnvVar
9
10
  from letta.schemas.environment_variables import SandboxEnvironmentVariableCreate, SandboxEnvironmentVariableUpdate
10
11
  from letta.schemas.sandbox_config import LocalSandboxConfig
@@ -12,7 +13,6 @@ from letta.schemas.sandbox_config import SandboxConfig as PydanticSandboxConfig
12
13
  from letta.schemas.sandbox_config import SandboxConfigCreate, SandboxConfigUpdate, SandboxType
13
14
  from letta.schemas.user import User as PydanticUser
14
15
  from letta.server.db import db_registry
15
- from letta.tracing import trace_method
16
16
  from letta.utils import enforce_types, printd
17
17
 
18
18
  logger = get_logger(__name__)
@@ -1,16 +1,25 @@
1
1
  import asyncio
2
+ from datetime import datetime
2
3
  from typing import List, Optional
3
4
 
5
+ from sqlalchemy import select, update
6
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
7
+ from sqlalchemy.exc import IntegrityError
8
+ from sqlalchemy.orm import selectinload
9
+
4
10
  from letta.orm.errors import NoResultFound
11
+ from letta.orm.file import FileContent as FileContentModel
5
12
  from letta.orm.file import FileMetadata as FileMetadataModel
6
13
  from letta.orm.source import Source as SourceModel
14
+ from letta.orm.sqlalchemy_base import AccessType
15
+ from letta.otel.tracing import trace_method
7
16
  from letta.schemas.agent import AgentState as PydanticAgentState
17
+ from letta.schemas.enums import FileProcessingStatus
8
18
  from letta.schemas.file import FileMetadata as PydanticFileMetadata
9
19
  from letta.schemas.source import Source as PydanticSource
10
20
  from letta.schemas.source import SourceUpdate
11
21
  from letta.schemas.user import User as PydanticUser
12
22
  from letta.server.db import db_registry
13
- from letta.tracing import trace_method
14
23
  from letta.utils import enforce_types, printd
15
24
 
16
25
 
@@ -142,41 +151,191 @@ class SourceManager:
142
151
 
143
152
  @enforce_types
144
153
  @trace_method
145
- async def create_file(self, file_metadata: PydanticFileMetadata, actor: PydanticUser) -> PydanticFileMetadata:
146
- """Create a new file based on the PydanticFileMetadata schema."""
147
- db_file = await self.get_file_by_id(file_metadata.id, actor=actor)
148
- if db_file:
149
- return db_file
150
- else:
151
- async with db_registry.async_session() as session:
154
+ async def create_file(
155
+ self,
156
+ file_metadata: PydanticFileMetadata,
157
+ actor: PydanticUser,
158
+ *,
159
+ text: Optional[str] = None,
160
+ ) -> PydanticFileMetadata:
161
+
162
+ # short-circuit if it already exists
163
+ existing = await self.get_file_by_id(file_metadata.id, actor=actor)
164
+ if existing:
165
+ return existing
166
+
167
+ async with db_registry.async_session() as session:
168
+ try:
152
169
  file_metadata.organization_id = actor.organization_id
153
- file_metadata = FileMetadataModel(**file_metadata.model_dump(to_orm=True, exclude_none=True))
154
- await file_metadata.create_async(session, actor=actor)
155
- return file_metadata.to_pydantic()
170
+ file_orm = FileMetadataModel(**file_metadata.model_dump(to_orm=True, exclude_none=True))
171
+ await file_orm.create_async(session, actor=actor, no_commit=True)
172
+
173
+ if text is not None:
174
+ content_orm = FileContentModel(file_id=file_orm.id, text=text)
175
+ await content_orm.create_async(session, actor=actor, no_commit=True)
176
+
177
+ await session.commit()
178
+ await session.refresh(file_orm)
179
+ return await file_orm.to_pydantic_async()
180
+
181
+ except IntegrityError:
182
+ await session.rollback()
183
+ return await self.get_file_by_id(file_metadata.id, actor=actor)
156
184
 
157
185
  # TODO: We make actor optional for now, but should most likely be enforced due to security reasons
158
186
  @enforce_types
159
187
  @trace_method
160
- async def get_file_by_id(self, file_id: str, actor: Optional[PydanticUser] = None) -> Optional[PydanticFileMetadata]:
161
- """Retrieve a file by its ID."""
188
+ async def get_file_by_id(
189
+ self,
190
+ file_id: str,
191
+ actor: Optional[PydanticUser] = None,
192
+ *,
193
+ include_content: bool = False,
194
+ ) -> Optional[PydanticFileMetadata]:
195
+ """Retrieve a file by its ID.
196
+
197
+ If `include_content=True`, the FileContent relationship is eagerly
198
+ loaded so `to_pydantic(include_content=True)` never triggers a
199
+ lazy SELECT (avoids MissingGreenlet).
200
+ """
162
201
  async with db_registry.async_session() as session:
163
202
  try:
164
- file = await FileMetadataModel.read_async(db_session=session, identifier=file_id, actor=actor)
165
- return file.to_pydantic()
203
+ if include_content:
204
+ # explicit eager load
205
+ query = (
206
+ select(FileMetadataModel).where(FileMetadataModel.id == file_id).options(selectinload(FileMetadataModel.content))
207
+ )
208
+ # apply org-scoping if actor provided
209
+ if actor:
210
+ query = FileMetadataModel.apply_access_predicate(
211
+ query,
212
+ actor,
213
+ access=["read"],
214
+ access_type=AccessType.ORGANIZATION,
215
+ )
216
+
217
+ result = await session.execute(query)
218
+ file_orm = result.scalar_one()
219
+ else:
220
+ # fast path (metadata only)
221
+ file_orm = await FileMetadataModel.read_async(
222
+ db_session=session,
223
+ identifier=file_id,
224
+ actor=actor,
225
+ )
226
+
227
+ return await file_orm.to_pydantic_async(include_content=include_content)
228
+
166
229
  except NoResultFound:
167
230
  return None
168
231
 
232
+ @enforce_types
233
+ @trace_method
234
+ async def update_file_status(
235
+ self,
236
+ *,
237
+ file_id: str,
238
+ actor: PydanticUser,
239
+ processing_status: Optional[FileProcessingStatus] = None,
240
+ error_message: Optional[str] = None,
241
+ ) -> PydanticFileMetadata:
242
+ """
243
+ Update processing_status and/or error_message on a FileMetadata row.
244
+
245
+ * 1st round-trip → UPDATE
246
+ * 2nd round-trip → SELECT fresh row (same as read_async)
247
+ """
248
+
249
+ if processing_status is None and error_message is None:
250
+ raise ValueError("Nothing to update")
251
+
252
+ values: dict[str, object] = {"updated_at": datetime.utcnow()}
253
+ if processing_status is not None:
254
+ values["processing_status"] = processing_status
255
+ if error_message is not None:
256
+ values["error_message"] = error_message
257
+
258
+ async with db_registry.async_session() as session:
259
+ # Fast in-place update – no ORM hydration
260
+ stmt = (
261
+ update(FileMetadataModel)
262
+ .where(
263
+ FileMetadataModel.id == file_id,
264
+ FileMetadataModel.organization_id == actor.organization_id,
265
+ )
266
+ .values(**values)
267
+ )
268
+ await session.execute(stmt)
269
+ await session.commit()
270
+
271
+ # Reload via normal accessor so we return a fully-attached object
272
+ file_orm = await FileMetadataModel.read_async(
273
+ db_session=session,
274
+ identifier=file_id,
275
+ actor=actor,
276
+ )
277
+ return await file_orm.to_pydantic_async()
278
+
279
+ @enforce_types
280
+ @trace_method
281
+ async def upsert_file_content(
282
+ self,
283
+ *,
284
+ file_id: str,
285
+ text: str,
286
+ actor: PydanticUser,
287
+ ) -> PydanticFileMetadata:
288
+ async with db_registry.async_session() as session:
289
+ await FileMetadataModel.read_async(session, file_id, actor)
290
+
291
+ dialect_name = session.bind.dialect.name
292
+
293
+ if dialect_name == "postgresql":
294
+ stmt = (
295
+ pg_insert(FileContentModel)
296
+ .values(file_id=file_id, text=text)
297
+ .on_conflict_do_update(
298
+ index_elements=[FileContentModel.file_id],
299
+ set_={"text": text},
300
+ )
301
+ )
302
+ await session.execute(stmt)
303
+ else:
304
+ # Emulate upsert for SQLite and others
305
+ stmt = select(FileContentModel).where(FileContentModel.file_id == file_id)
306
+ result = await session.execute(stmt)
307
+ existing = result.scalar_one_or_none()
308
+
309
+ if existing:
310
+ await session.execute(update(FileContentModel).where(FileContentModel.file_id == file_id).values(text=text))
311
+ else:
312
+ session.add(FileContentModel(file_id=file_id, text=text))
313
+
314
+ await session.commit()
315
+
316
+ # Reload with content
317
+ query = select(FileMetadataModel).options(selectinload(FileMetadataModel.content)).where(FileMetadataModel.id == file_id)
318
+ result = await session.execute(query)
319
+ return await result.scalar_one().to_pydantic_async(include_content=True)
320
+
169
321
  @enforce_types
170
322
  @trace_method
171
323
  async def list_files(
172
- self, source_id: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50
324
+ self, source_id: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50, include_content: bool = False
173
325
  ) -> List[PydanticFileMetadata]:
174
326
  """List all files with optional pagination."""
175
327
  async with db_registry.async_session() as session:
328
+ options = [selectinload(FileMetadataModel.content)] if include_content else None
329
+
176
330
  files = await FileMetadataModel.list_async(
177
- db_session=session, after=after, limit=limit, organization_id=actor.organization_id, source_id=source_id
331
+ db_session=session,
332
+ after=after,
333
+ limit=limit,
334
+ organization_id=actor.organization_id,
335
+ source_id=source_id,
336
+ query_options=options,
178
337
  )
179
- return [file.to_pydantic() for file in files]
338
+ return [await file.to_pydantic_async(include_content=include_content) for file in files]
180
339
 
181
340
  @enforce_types
182
341
  @trace_method
@@ -185,4 +344,4 @@ class SourceManager:
185
344
  async with db_registry.async_session() as session:
186
345
  file = await FileMetadataModel.read_async(db_session=session, identifier=file_id)
187
346
  await file.hard_delete_async(db_session=session, actor=actor)
188
- return file.to_pydantic()
347
+ return await file.to_pydantic_async()
@@ -5,16 +5,16 @@ from sqlalchemy import select
5
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
6
  from sqlalchemy.orm import Session
7
7
 
8
+ from letta.helpers.singleton import singleton
8
9
  from letta.orm.errors import NoResultFound
9
10
  from letta.orm.job import Job as JobModel
10
11
  from letta.orm.sqlalchemy_base import AccessType
11
12
  from letta.orm.step import Step as StepModel
13
+ from letta.otel.tracing import get_trace_id, trace_method
12
14
  from letta.schemas.openai.chat_completion_response import UsageStatistics
13
15
  from letta.schemas.step import Step as PydanticStep
14
16
  from letta.schemas.user import User as PydanticUser
15
17
  from letta.server.db import db_registry
16
- from letta.services.helpers.noop_helper import singleton
17
- from letta.tracing import get_trace_id, trace_method
18
18
  from letta.utils import enforce_types
19
19
 
20
20
 
@@ -6,11 +6,11 @@ from typing import List, Optional, Tuple, Union
6
6
  from letta.agents.ephemeral_summary_agent import EphemeralSummaryAgent
7
7
  from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
8
8
  from letta.log import get_logger
9
+ from letta.otel.tracing import trace_method
9
10
  from letta.schemas.enums import MessageRole
10
11
  from letta.schemas.letta_message_content import TextContent
11
12
  from letta.schemas.message import Message, MessageCreate
12
13
  from letta.services.summarizer.enums import SummarizationMode
13
- from letta.tracing import trace_method
14
14
 
15
15
  logger = get_logger(__name__)
16
16
 
@@ -1,11 +1,11 @@
1
1
  from letta.helpers.json_helpers import json_dumps, json_loads
2
+ from letta.helpers.singleton import singleton
2
3
  from letta.orm.provider_trace import ProviderTrace as ProviderTraceModel
3
4
  from letta.schemas.provider_trace import ProviderTrace as PydanticProviderTrace
4
5
  from letta.schemas.provider_trace import ProviderTraceCreate
5
6
  from letta.schemas.step import Step as PydanticStep
6
7
  from letta.schemas.user import User as PydanticUser
7
8
  from letta.server.db import db_registry
8
- from letta.services.helpers.noop_helper import singleton
9
9
  from letta.utils import enforce_types
10
10
 
11
11
 
@@ -0,0 +1,117 @@
1
+ import json
2
+ from textwrap import shorten
3
+ from typing import Any, Dict, Literal, Optional
4
+
5
+ from letta.constants import WEB_SEARCH_CLIP_CONTENT, WEB_SEARCH_INCLUDE_SCORE, WEB_SEARCH_SEPARATOR
6
+ from letta.otel.tracing import trace_method
7
+ from letta.schemas.agent import AgentState
8
+ from letta.schemas.sandbox_config import SandboxConfig
9
+ from letta.schemas.tool import Tool
10
+ from letta.schemas.tool_execution_result import ToolExecutionResult
11
+ from letta.schemas.user import User
12
+ from letta.services.tool_executor.tool_executor_base import ToolExecutor
13
+ from letta.settings import tool_settings
14
+
15
+
16
+ class LettaBuiltinToolExecutor(ToolExecutor):
17
+ """Executor for built in Letta tools."""
18
+
19
+ @trace_method
20
+ async def execute(
21
+ self,
22
+ function_name: str,
23
+ function_args: dict,
24
+ tool: Tool,
25
+ actor: User,
26
+ agent_state: Optional[AgentState] = None,
27
+ sandbox_config: Optional[SandboxConfig] = None,
28
+ sandbox_env_vars: Optional[Dict[str, Any]] = None,
29
+ ) -> ToolExecutionResult:
30
+ function_map = {"run_code": self.run_code, "web_search": self.web_search}
31
+
32
+ if function_name not in function_map:
33
+ raise ValueError(f"Unknown function: {function_name}")
34
+
35
+ # Execute the appropriate function
36
+ function_args_copy = function_args.copy() # Make a copy to avoid modifying the original
37
+ function_response = await function_map[function_name](**function_args_copy)
38
+
39
+ return ToolExecutionResult(
40
+ status="success",
41
+ func_return=function_response,
42
+ agent_state=agent_state,
43
+ )
44
+
45
+ async def run_code(self, code: str, language: Literal["python", "js", "ts", "r", "java"]) -> str:
46
+ from e2b_code_interpreter import AsyncSandbox
47
+
48
+ if tool_settings.e2b_api_key is None:
49
+ raise ValueError("E2B_API_KEY is not set")
50
+
51
+ sbx = await AsyncSandbox.create(api_key=tool_settings.e2b_api_key)
52
+ params = {"code": code}
53
+ if language != "python":
54
+ # Leave empty for python
55
+ params["language"] = language
56
+
57
+ res = self._llm_friendly_result(await sbx.run_code(**params))
58
+ return json.dumps(res, ensure_ascii=False)
59
+
60
+ def _llm_friendly_result(self, res):
61
+ out = {
62
+ "results": [r.text if hasattr(r, "text") else str(r) for r in res.results],
63
+ "logs": {
64
+ "stdout": getattr(res.logs, "stdout", []),
65
+ "stderr": getattr(res.logs, "stderr", []),
66
+ },
67
+ }
68
+ err = getattr(res, "error", None)
69
+ if err is not None:
70
+ out["error"] = err
71
+ return out
72
+
73
+ async def web_search(agent_state: "AgentState", query: str) -> str:
74
+ """
75
+ Search the web for information.
76
+ Args:
77
+ query (str): The query to search the web for.
78
+ Returns:
79
+ str: The search results.
80
+ """
81
+
82
+ try:
83
+ from tavily import AsyncTavilyClient
84
+ except ImportError:
85
+ raise ImportError("tavily is not installed in the tool execution environment")
86
+
87
+ # Check if the API key exists
88
+ if tool_settings.tavily_api_key is None:
89
+ raise ValueError("TAVILY_API_KEY is not set")
90
+
91
+ # Instantiate client and search
92
+ tavily_client = AsyncTavilyClient(api_key=tool_settings.tavily_api_key)
93
+ search_results = await tavily_client.search(query=query, auto_parameters=True)
94
+
95
+ results = search_results.get("results", [])
96
+ if not results:
97
+ return "No search results found."
98
+
99
+ # ---- format for the LLM -------------------------------------------------
100
+ formatted_blocks = []
101
+ for idx, item in enumerate(results, start=1):
102
+ title = item.get("title") or "Untitled"
103
+ url = item.get("url") or "Unknown URL"
104
+ # keep each content snippet reasonably short so you don’t blow up context
105
+ content = (
106
+ shorten(item.get("content", "").strip(), width=600, placeholder=" …")
107
+ if WEB_SEARCH_CLIP_CONTENT
108
+ else item.get("content", "").strip()
109
+ )
110
+ score = item.get("score")
111
+ if WEB_SEARCH_INCLUDE_SCORE:
112
+ block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Relevance score: {score:.4f}\n" f"Content: {content}\n"
113
+ else:
114
+ block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Content: {content}\n"
115
+ formatted_blocks.append(block)
116
+
117
+ return WEB_SEARCH_SEPARATOR.join(formatted_blocks)
@@ -0,0 +1,53 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from letta.constants import COMPOSIO_ENTITY_ENV_VAR_KEY
4
+ from letta.functions.composio_helpers import execute_composio_action_async, generate_composio_action_from_func_name
5
+ from letta.helpers.composio_helpers import get_composio_api_key_async
6
+ from letta.otel.tracing import trace_method
7
+ from letta.schemas.agent import AgentState
8
+ from letta.schemas.sandbox_config import SandboxConfig
9
+ from letta.schemas.tool import Tool
10
+ from letta.schemas.tool_execution_result import ToolExecutionResult
11
+ from letta.schemas.user import User
12
+ from letta.services.tool_executor.tool_executor_base import ToolExecutor
13
+
14
+
15
+ class ExternalComposioToolExecutor(ToolExecutor):
16
+ """Executor for external Composio tools."""
17
+
18
+ @trace_method
19
+ async def execute(
20
+ self,
21
+ function_name: str,
22
+ function_args: dict,
23
+ tool: Tool,
24
+ actor: User,
25
+ agent_state: Optional[AgentState] = None,
26
+ sandbox_config: Optional[SandboxConfig] = None,
27
+ sandbox_env_vars: Optional[Dict[str, Any]] = None,
28
+ ) -> ToolExecutionResult:
29
+ assert agent_state is not None, "Agent state is required for external Composio tools"
30
+ action_name = generate_composio_action_from_func_name(tool.name)
31
+
32
+ # Get entity ID from the agent_state
33
+ entity_id = self._get_entity_id(agent_state)
34
+
35
+ # Get composio_api_key
36
+ composio_api_key = await get_composio_api_key_async(actor=actor)
37
+
38
+ # TODO (matt): Roll in execute_composio_action into this class
39
+ function_response = await execute_composio_action_async(
40
+ action_name=action_name, args=function_args, api_key=composio_api_key, entity_id=entity_id
41
+ )
42
+
43
+ return ToolExecutionResult(
44
+ status="success",
45
+ func_return=function_response,
46
+ )
47
+
48
+ def _get_entity_id(self, agent_state: AgentState) -> Optional[str]:
49
+ """Extract the entity ID from environment variables."""
50
+ for env_var in agent_state.tool_exec_environment_variables:
51
+ if env_var.key == COMPOSIO_ENTITY_ENV_VAR_KEY:
52
+ return env_var.value
53
+ return None