letta-nightly 0.6.27.dev20250220104103__py3-none-any.whl → 0.6.28.dev20250220163833__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.

Potentially problematic release.


This version of letta-nightly might be problematic. Click here for more details.

Files changed (60) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +13 -1
  3. letta/client/client.py +2 -0
  4. letta/constants.py +2 -0
  5. letta/functions/schema_generator.py +6 -6
  6. letta/helpers/converters.py +153 -0
  7. letta/helpers/tool_rule_solver.py +11 -1
  8. letta/llm_api/anthropic.py +10 -5
  9. letta/llm_api/aws_bedrock.py +1 -1
  10. letta/llm_api/deepseek.py +303 -0
  11. letta/llm_api/llm_api_tools.py +81 -1
  12. letta/llm_api/openai.py +13 -0
  13. letta/local_llm/chat_completion_proxy.py +15 -2
  14. letta/local_llm/lmstudio/api.py +75 -1
  15. letta/orm/__init__.py +1 -0
  16. letta/orm/agent.py +14 -5
  17. letta/orm/custom_columns.py +31 -110
  18. letta/orm/identity.py +39 -0
  19. letta/orm/organization.py +2 -0
  20. letta/schemas/agent.py +13 -1
  21. letta/schemas/identity.py +44 -0
  22. letta/schemas/llm_config.py +2 -0
  23. letta/schemas/message.py +1 -1
  24. letta/schemas/openai/chat_completion_response.py +2 -0
  25. letta/schemas/providers.py +72 -1
  26. letta/schemas/tool_rule.py +9 -1
  27. letta/serialize_schemas/__init__.py +1 -0
  28. letta/serialize_schemas/agent.py +36 -0
  29. letta/serialize_schemas/base.py +12 -0
  30. letta/serialize_schemas/custom_fields.py +69 -0
  31. letta/serialize_schemas/message.py +15 -0
  32. letta/server/db.py +111 -0
  33. letta/server/rest_api/app.py +8 -0
  34. letta/server/rest_api/interface.py +114 -9
  35. letta/server/rest_api/routers/v1/__init__.py +2 -0
  36. letta/server/rest_api/routers/v1/agents.py +7 -1
  37. letta/server/rest_api/routers/v1/identities.py +111 -0
  38. letta/server/server.py +13 -116
  39. letta/services/agent_manager.py +54 -6
  40. letta/services/block_manager.py +1 -1
  41. letta/services/helpers/agent_manager_helper.py +15 -0
  42. letta/services/identity_manager.py +140 -0
  43. letta/services/job_manager.py +1 -1
  44. letta/services/message_manager.py +1 -1
  45. letta/services/organization_manager.py +1 -1
  46. letta/services/passage_manager.py +1 -1
  47. letta/services/provider_manager.py +1 -1
  48. letta/services/sandbox_config_manager.py +1 -1
  49. letta/services/source_manager.py +1 -1
  50. letta/services/step_manager.py +1 -1
  51. letta/services/tool_manager.py +1 -1
  52. letta/services/user_manager.py +1 -1
  53. letta/settings.py +3 -0
  54. letta/tracing.py +205 -0
  55. letta/utils.py +4 -0
  56. {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/METADATA +9 -2
  57. {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/RECORD +60 -47
  58. {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/LICENSE +0 -0
  59. {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/WHEEL +0 -0
  60. {letta_nightly-0.6.27.dev20250220104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/entry_points.txt +0 -0
@@ -27,10 +27,14 @@ from letta.schemas.message import MessageCreate
27
27
  from letta.schemas.passage import Passage as PydanticPassage
28
28
  from letta.schemas.source import Source as PydanticSource
29
29
  from letta.schemas.tool import Tool as PydanticTool
30
+ from letta.schemas.tool_rule import ContinueToolRule as PydanticContinueToolRule
31
+ from letta.schemas.tool_rule import TerminalToolRule as PydanticTerminalToolRule
30
32
  from letta.schemas.tool_rule import ToolRule as PydanticToolRule
31
33
  from letta.schemas.user import User as PydanticUser
34
+ from letta.serialize_schemas import SerializedAgentSchema
32
35
  from letta.services.block_manager import BlockManager
33
36
  from letta.services.helpers.agent_manager_helper import (
37
+ _process_identity,
34
38
  _process_relationship,
35
39
  _process_tags,
36
40
  check_supports_structured_output,
@@ -39,6 +43,7 @@ from letta.services.helpers.agent_manager_helper import (
39
43
  initialize_message_sequence,
40
44
  package_initial_message_sequence,
41
45
  )
46
+ from letta.services.identity_manager import IdentityManager
42
47
  from letta.services.message_manager import MessageManager
43
48
  from letta.services.source_manager import SourceManager
44
49
  from letta.services.tool_manager import ToolManager
@@ -53,13 +58,14 @@ class AgentManager:
53
58
  """Manager class to handle business logic related to Agents."""
54
59
 
55
60
  def __init__(self):
56
- from letta.server.server import db_context
61
+ from letta.server.db import db_context
57
62
 
58
63
  self.session_maker = db_context
59
64
  self.block_manager = BlockManager()
60
65
  self.tool_manager = ToolManager()
61
66
  self.source_manager = SourceManager()
62
67
  self.message_manager = MessageManager()
68
+ self.identity_manager = IdentityManager()
63
69
 
64
70
  # ======================================================================================================================
65
71
  # Basic CRUD operations
@@ -75,10 +81,6 @@ class AgentManager:
75
81
  if not agent_create.llm_config or not agent_create.embedding_config:
76
82
  raise ValueError("llm_config and embedding_config are required")
77
83
 
78
- # Check tool rules are valid
79
- if agent_create.tool_rules:
80
- check_supports_structured_output(model=agent_create.llm_config.model, tool_rules=agent_create.tool_rules)
81
-
82
84
  # create blocks (note: cannot be linked into the agent_id is created)
83
85
  block_ids = list(agent_create.block_ids or []) # Create a local copy to avoid modifying the original
84
86
  if agent_create.memory_blocks:
@@ -98,6 +100,25 @@ class AgentManager:
98
100
  # Remove duplicates
99
101
  tool_names = list(set(tool_names))
100
102
 
103
+ # add default tool rules
104
+ if agent_create.include_base_tool_rules:
105
+ if not agent_create.tool_rules:
106
+ tool_rules = []
107
+ else:
108
+ tool_rules = agent_create.tool_rules
109
+
110
+ # apply default tool rules
111
+ for tool_name in tool_names:
112
+ if tool_name == "send_message" or tool_name == "send_message_to_agent_async":
113
+ tool_rules.append(PydanticTerminalToolRule(tool_name=tool_name))
114
+ elif tool_name in BASE_TOOLS:
115
+ tool_rules.append(PydanticContinueToolRule(tool_name=tool_name))
116
+ else:
117
+ tool_rules = agent_create.tool_rules
118
+ # Check tool rules are valid
119
+ if agent_create.tool_rules:
120
+ check_supports_structured_output(model=agent_create.llm_config.model, tool_rules=agent_create.tool_rules)
121
+
101
122
  tool_ids = agent_create.tool_ids or []
102
123
  for tool_name in tool_names:
103
124
  tool = self.tool_manager.get_tool_by_name(tool_name=tool_name, actor=actor)
@@ -119,11 +140,12 @@ class AgentManager:
119
140
  tags=agent_create.tags or [],
120
141
  description=agent_create.description,
121
142
  metadata=agent_create.metadata,
122
- tool_rules=agent_create.tool_rules,
143
+ tool_rules=tool_rules,
123
144
  actor=actor,
124
145
  project_id=agent_create.project_id,
125
146
  template_id=agent_create.template_id,
126
147
  base_template_id=agent_create.base_template_id,
148
+ identifier_key=agent_create.identifier_key,
127
149
  message_buffer_autoclear=agent_create.message_buffer_autoclear,
128
150
  )
129
151
 
@@ -187,6 +209,7 @@ class AgentManager:
187
209
  project_id: Optional[str] = None,
188
210
  template_id: Optional[str] = None,
189
211
  base_template_id: Optional[str] = None,
212
+ identifier_key: Optional[str] = None,
190
213
  message_buffer_autoclear: bool = False,
191
214
  ) -> PydanticAgentState:
192
215
  """Create a new agent."""
@@ -214,6 +237,10 @@ class AgentManager:
214
237
  _process_relationship(session, new_agent, "sources", SourceModel, source_ids, replace=True)
215
238
  _process_relationship(session, new_agent, "core_memory", BlockModel, block_ids, replace=True)
216
239
  _process_tags(new_agent, tags, replace=True)
240
+ if identifier_key is not None:
241
+ identity = self.identity_manager.get_identity_from_identifier_key(identifier_key)
242
+ _process_identity(new_agent, identifier_key, identity)
243
+
217
244
  new_agent.create(session, actor=actor)
218
245
 
219
246
  # Convert to PydanticAgentState and return
@@ -286,6 +313,9 @@ class AgentManager:
286
313
  _process_relationship(session, agent, "core_memory", BlockModel, agent_update.block_ids, replace=True)
287
314
  if agent_update.tags is not None:
288
315
  _process_tags(agent, agent_update.tags, replace=True)
316
+ if agent_update.identifier_key is not None:
317
+ identity = self.identity_manager.get_identity_from_identifier_key(agent_update.identifier_key)
318
+ _process_identity(agent, agent_update.identifier_key, identity)
289
319
 
290
320
  # Commit and refresh the agent
291
321
  agent.update(session, actor=actor)
@@ -355,6 +385,24 @@ class AgentManager:
355
385
  agent = AgentModel.read(db_session=session, identifier=agent_id, actor=actor)
356
386
  agent.hard_delete(session)
357
387
 
388
+ @enforce_types
389
+ def serialize(self, agent_id: str, actor: PydanticUser) -> dict:
390
+ with self.session_maker() as session:
391
+ # Retrieve the agent
392
+ agent = AgentModel.read(db_session=session, identifier=agent_id, actor=actor)
393
+ schema = SerializedAgentSchema(session=session)
394
+ return schema.dump(agent)
395
+
396
+ @enforce_types
397
+ def deserialize(self, serialized_agent: dict, actor: PydanticUser) -> PydanticAgentState:
398
+ # TODO: Use actor to override fields
399
+ with self.session_maker() as session:
400
+ schema = SerializedAgentSchema(session=session)
401
+ agent = schema.load(serialized_agent, session=session)
402
+ agent.organization_id = actor.organization_id
403
+ agent = agent.create(session, actor=actor)
404
+ return agent.to_pydantic()
405
+
358
406
  # ======================================================================================================================
359
407
  # Per Agent Environment Variable Management
360
408
  # ======================================================================================================================
@@ -16,7 +16,7 @@ class BlockManager:
16
16
 
17
17
  def __init__(self):
18
18
  # Fetching the db_context similarly as in ToolManager
19
- from letta.server.server import db_context
19
+ from letta.server.db import db_context
20
20
 
21
21
  self.session_maker = db_context
22
22
 
@@ -11,6 +11,7 @@ from letta.orm.errors import NoResultFound
11
11
  from letta.prompts import gpt_system
12
12
  from letta.schemas.agent import AgentState, AgentType
13
13
  from letta.schemas.enums import MessageRole
14
+ from letta.schemas.identity import Identity
14
15
  from letta.schemas.memory import Memory
15
16
  from letta.schemas.message import Message, MessageCreate, TextContent
16
17
  from letta.schemas.tool_rule import ToolRule
@@ -84,6 +85,20 @@ def _process_tags(agent: AgentModel, tags: List[str], replace=True):
84
85
  agent.tags.extend([tag for tag in new_tags if tag.tag not in existing_tags])
85
86
 
86
87
 
88
+ def _process_identity(agent: AgentModel, identifier_key: str, identity: Identity):
89
+ """
90
+ Handles identity for an agent.
91
+
92
+ Args:
93
+ agent: The AgentModel instance.
94
+ identifier_key: The identifier key of the identity to set or update.
95
+ identity: The Identity object to set or update.
96
+ """
97
+ agent.identifier_key = identifier_key
98
+ agent.identity = identity
99
+ agent.identity_id = identity.id
100
+
101
+
87
102
  def derive_system_message(agent_type: AgentType, system: Optional[str] = None):
88
103
  if system is None:
89
104
  # TODO: don't hardcode
@@ -0,0 +1,140 @@
1
+ from typing import List, Optional
2
+
3
+ from fastapi import HTTPException
4
+ from sqlalchemy.orm import Session
5
+
6
+ from letta.orm.agent import Agent as AgentModel
7
+ from letta.orm.identity import Identity as IdentityModel
8
+ from letta.schemas.identity import Identity as PydanticIdentity
9
+ from letta.schemas.identity import IdentityCreate, IdentityType, IdentityUpdate
10
+ from letta.schemas.user import User as PydanticUser
11
+ from letta.utils import enforce_types
12
+
13
+
14
+ class IdentityManager:
15
+
16
+ def __init__(self):
17
+ from letta.server.db import db_context
18
+
19
+ self.session_maker = db_context
20
+
21
+ @enforce_types
22
+ def list_identities(
23
+ self,
24
+ name: Optional[str] = None,
25
+ project_id: Optional[str] = None,
26
+ identity_type: Optional[IdentityType] = None,
27
+ before: Optional[str] = None,
28
+ after: Optional[str] = None,
29
+ limit: Optional[int] = 50,
30
+ actor: PydanticUser = None,
31
+ ) -> list[PydanticIdentity]:
32
+ with self.session_maker() as session:
33
+ filters = {"organization_id": actor.organization_id}
34
+ if project_id:
35
+ filters["project_id"] = project_id
36
+ if identity_type:
37
+ filters["identity_type"] = identity_type
38
+ identities = IdentityModel.list(
39
+ db_session=session,
40
+ query_text=name,
41
+ before=before,
42
+ after=after,
43
+ limit=limit,
44
+ **filters,
45
+ )
46
+ return [identity.to_pydantic() for identity in identities]
47
+
48
+ @enforce_types
49
+ def get_identity_from_identifier_key(self, identifier_key: str) -> PydanticIdentity:
50
+ with self.session_maker() as session:
51
+ identity = IdentityModel.read(db_session=session, identifier_key=identifier_key)
52
+ return identity.to_pydantic()
53
+
54
+ @enforce_types
55
+ def create_identity(self, identity: IdentityCreate, actor: PydanticUser) -> PydanticIdentity:
56
+ with self.session_maker() as session:
57
+ new_identity = IdentityModel(**identity.model_dump(exclude={"agent_ids"}, exclude_unset=True))
58
+ new_identity.organization_id = actor.organization_id
59
+ self._process_agent_relationship(session=session, identity=new_identity, agent_ids=identity.agent_ids, allow_partial=False)
60
+ new_identity.create(session, actor=actor)
61
+ return new_identity.to_pydantic()
62
+
63
+ @enforce_types
64
+ def upsert_identity(self, identity: IdentityCreate, actor: PydanticUser) -> PydanticIdentity:
65
+ with self.session_maker() as session:
66
+ existing_identity = IdentityModel.read(
67
+ db_session=session,
68
+ identifier_key=identity.identifier_key,
69
+ project_id=identity.project_id,
70
+ organization_id=actor.organization_id,
71
+ )
72
+
73
+ if existing_identity is None:
74
+ return self.create_identity(identity=identity, actor=actor)
75
+ else:
76
+ if existing_identity.identifier_key != identity.identifier_key:
77
+ raise HTTPException(status_code=400, detail="Identifier key is an immutable field")
78
+ if existing_identity.project_id != identity.project_id:
79
+ raise HTTPException(status_code=400, detail="Project id is an immutable field")
80
+ identity_update = IdentityUpdate(name=identity.name, identity_type=identity.identity_type, agent_ids=identity.agent_ids)
81
+ return self.update_identity_by_key(identity.identifier_key, identity_update, actor, replace=True)
82
+
83
+ @enforce_types
84
+ def update_identity_by_key(
85
+ self, identifier_key: str, identity: IdentityUpdate, actor: PydanticUser, replace: bool = False
86
+ ) -> PydanticIdentity:
87
+ with self.session_maker() as session:
88
+ try:
89
+ existing_identity = IdentityModel.read(db_session=session, identifier_key=identifier_key)
90
+ except NoResultFound:
91
+ raise HTTPException(status_code=404, detail="Identity not found")
92
+ if existing_identity.organization_id != actor.organization_id:
93
+ raise HTTPException(status_code=403, detail="Forbidden")
94
+
95
+ existing_identity.name = identity.name if identity.name is not None else existing_identity.name
96
+ existing_identity.identity_type = (
97
+ identity.identity_type if identity.identity_type is not None else existing_identity.identity_type
98
+ )
99
+ self._process_agent_relationship(
100
+ session=session, identity=existing_identity, agent_ids=identity.agent_ids, allow_partial=False, replace=replace
101
+ )
102
+ existing_identity.update(session, actor=actor)
103
+ return existing_identity.to_pydantic()
104
+
105
+ @enforce_types
106
+ def delete_identity_by_key(self, identifier_key: str, actor: PydanticUser) -> None:
107
+ with self.session_maker() as session:
108
+ identity = IdentityModel.read(db_session=session, identifier_key=identifier_key)
109
+ if identity is None:
110
+ raise HTTPException(status_code=404, detail="Identity not found")
111
+ if identity.organization_id != actor.organization_id:
112
+ raise HTTPException(status_code=403, detail="Forbidden")
113
+ session.delete(identity)
114
+ session.commit()
115
+
116
+ def _process_agent_relationship(
117
+ self, session: Session, identity: IdentityModel, agent_ids: List[str], allow_partial=False, replace=True
118
+ ):
119
+ current_relationship = getattr(identity, "agents", [])
120
+ if not agent_ids:
121
+ if replace:
122
+ setattr(identity, "agents", [])
123
+ return
124
+
125
+ # Retrieve models for the provided IDs
126
+ found_items = session.query(AgentModel).filter(AgentModel.id.in_(agent_ids)).all()
127
+
128
+ # Validate all items are found if allow_partial is False
129
+ if not allow_partial and len(found_items) != len(agent_ids):
130
+ missing = set(agent_ids) - {item.id for item in found_items}
131
+ raise NoResultFound(f"Items not found in agents: {missing}")
132
+
133
+ if replace:
134
+ # Replace the relationship
135
+ setattr(identity, "agents", found_items)
136
+ else:
137
+ # Extend the relationship (only add new items)
138
+ current_ids = {item.id for item in current_relationship}
139
+ new_items = [item for item in found_items if item.id not in current_ids]
140
+ current_relationship.extend(new_items)
@@ -29,7 +29,7 @@ class JobManager:
29
29
 
30
30
  def __init__(self):
31
31
  # Fetching the db_context similarly as in OrganizationManager
32
- from letta.server.server import db_context
32
+ from letta.server.db import db_context
33
33
 
34
34
  self.session_maker = db_context
35
35
 
@@ -16,7 +16,7 @@ class MessageManager:
16
16
  """Manager class to handle business logic related to Messages."""
17
17
 
18
18
  def __init__(self):
19
- from letta.server.server import db_context
19
+ from letta.server.db import db_context
20
20
 
21
21
  self.session_maker = db_context
22
22
 
@@ -16,7 +16,7 @@ class OrganizationManager:
16
16
  # TODO: Please refactor this out
17
17
  # I am currently working on a ORM refactor and would like to make a more minimal set of changes
18
18
  # - Matt
19
- from letta.server.server import db_context
19
+ from letta.server.db import db_context
20
20
 
21
21
  self.session_maker = db_context
22
22
 
@@ -16,7 +16,7 @@ class PassageManager:
16
16
  """Manager class to handle business logic related to Passages."""
17
17
 
18
18
  def __init__(self):
19
- from letta.server.server import db_context
19
+ from letta.server.db import db_context
20
20
 
21
21
  self.session_maker = db_context
22
22
 
@@ -10,7 +10,7 @@ from letta.utils import enforce_types
10
10
  class ProviderManager:
11
11
 
12
12
  def __init__(self):
13
- from letta.server.server import db_context
13
+ from letta.server.db import db_context
14
14
 
15
15
  self.session_maker = db_context
16
16
 
@@ -20,7 +20,7 @@ class SandboxConfigManager:
20
20
  """Manager class to handle business logic related to SandboxConfig and SandboxEnvironmentVariable."""
21
21
 
22
22
  def __init__(self):
23
- from letta.server.server import db_context
23
+ from letta.server.db import db_context
24
24
 
25
25
  self.session_maker = db_context
26
26
 
@@ -15,7 +15,7 @@ class SourceManager:
15
15
  """Manager class to handle business logic related to Sources."""
16
16
 
17
17
  def __init__(self):
18
- from letta.server.server import db_context
18
+ from letta.server.db import db_context
19
19
 
20
20
  self.session_maker = db_context
21
21
 
@@ -17,7 +17,7 @@ from letta.utils import enforce_types
17
17
  class StepManager:
18
18
 
19
19
  def __init__(self):
20
- from letta.server.server import db_context
20
+ from letta.server.db import db_context
21
21
 
22
22
  self.session_maker = db_context
23
23
 
@@ -31,7 +31,7 @@ class ToolManager:
31
31
 
32
32
  def __init__(self):
33
33
  # Fetching the db_context similarly as in OrganizationManager
34
- from letta.server.server import db_context
34
+ from letta.server.db import db_context
35
35
 
36
36
  self.session_maker = db_context
37
37
 
@@ -17,7 +17,7 @@ class UserManager:
17
17
 
18
18
  def __init__(self):
19
19
  # Fetching the db_context similarly as in OrganizationManager
20
- from letta.server.server import db_context
20
+ from letta.server.db import db_context
21
21
 
22
22
  self.session_maker = db_context
23
23
 
letta/settings.py CHANGED
@@ -60,6 +60,9 @@ class ModelSettings(BaseSettings):
60
60
  openai_api_key: Optional[str] = None
61
61
  openai_api_base: str = "https://api.openai.com/v1"
62
62
 
63
+ # deepseek
64
+ deepseek_api_key: Optional[str] = None
65
+
63
66
  # groq
64
67
  groq_api_key: Optional[str] = None
65
68
 
letta/tracing.py ADDED
@@ -0,0 +1,205 @@
1
+ import asyncio
2
+ import inspect
3
+ import sys
4
+ import time
5
+ from functools import wraps
6
+ from typing import Any, Dict, Optional
7
+
8
+ from fastapi import Request
9
+ from opentelemetry import trace
10
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
11
+ from opentelemetry.instrumentation.requests import RequestsInstrumentor
12
+ from opentelemetry.sdk.resources import Resource
13
+ from opentelemetry.sdk.trace import TracerProvider
14
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
15
+ from opentelemetry.trace import Span, Status, StatusCode
16
+
17
+ # Get a tracer instance - will be no-op until setup_tracing is called
18
+ tracer = trace.get_tracer(__name__)
19
+
20
+ # Track if tracing has been initialized
21
+ _is_tracing_initialized = False
22
+
23
+
24
+ def is_pytest_environment():
25
+ """Check if we're running in pytest"""
26
+ return "pytest" in sys.modules
27
+
28
+
29
+ def trace_method(name=None):
30
+ """Decorator to add tracing to a method"""
31
+
32
+ def decorator(func):
33
+ @wraps(func)
34
+ async def async_wrapper(*args, **kwargs):
35
+ # Skip tracing if not initialized
36
+ if not _is_tracing_initialized:
37
+ return await func(*args, **kwargs)
38
+
39
+ span_name = name or func.__name__
40
+ with tracer.start_as_current_span(span_name) as span:
41
+ span.set_attribute("code.namespace", inspect.getmodule(func).__name__)
42
+ span.set_attribute("code.function", func.__name__)
43
+
44
+ if len(args) > 0 and hasattr(args[0], "__class__"):
45
+ span.set_attribute("code.class", args[0].__class__.__name__)
46
+
47
+ request = _extract_request_info(args, span)
48
+ if request and len(request) > 0:
49
+ span.set_attribute("agent.id", kwargs.get("agent_id"))
50
+ span.set_attribute("actor.id", request.get("http.user_id"))
51
+
52
+ try:
53
+ result = await func(*args, **kwargs)
54
+ span.set_status(Status(StatusCode.OK))
55
+ return result
56
+ except Exception as e:
57
+ span.set_status(Status(StatusCode.ERROR))
58
+ span.record_exception(e)
59
+ raise
60
+
61
+ @wraps(func)
62
+ def sync_wrapper(*args, **kwargs):
63
+ # Skip tracing if not initialized
64
+ if not _is_tracing_initialized:
65
+ return func(*args, **kwargs)
66
+
67
+ span_name = name or func.__name__
68
+ with tracer.start_as_current_span(span_name) as span:
69
+ span.set_attribute("code.namespace", inspect.getmodule(func).__name__)
70
+ span.set_attribute("code.function", func.__name__)
71
+
72
+ if len(args) > 0 and hasattr(args[0], "__class__"):
73
+ span.set_attribute("code.class", args[0].__class__.__name__)
74
+
75
+ request = _extract_request_info(args, span)
76
+ if request and len(request) > 0:
77
+ span.set_attribute("agent.id", kwargs.get("agent_id"))
78
+ span.set_attribute("actor.id", request.get("http.user_id"))
79
+
80
+ try:
81
+ result = func(*args, **kwargs)
82
+ span.set_status(Status(StatusCode.OK))
83
+ return result
84
+ except Exception as e:
85
+ span.set_status(Status(StatusCode.ERROR))
86
+ span.record_exception(e)
87
+ raise
88
+
89
+ return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
90
+
91
+ return decorator
92
+
93
+
94
+ def log_attributes(attributes: Dict[str, Any]) -> None:
95
+ """
96
+ Log multiple attributes to the current active span.
97
+
98
+ Args:
99
+ attributes: Dictionary of attribute key-value pairs
100
+ """
101
+ current_span = trace.get_current_span()
102
+ if current_span:
103
+ current_span.set_attributes(attributes)
104
+
105
+
106
+ def log_event(name: str, attributes: Optional[Dict[str, Any]] = None, timestamp: Optional[int] = None) -> None:
107
+ """
108
+ Log an event to the current active span.
109
+
110
+ Args:
111
+ name: Name of the event
112
+ attributes: Optional dictionary of event attributes
113
+ timestamp: Optional timestamp in nanoseconds
114
+ """
115
+ current_span = trace.get_current_span()
116
+ if current_span:
117
+ if timestamp is None:
118
+ timestamp = int(time.perf_counter_ns())
119
+
120
+ current_span.add_event(name=name, attributes=attributes, timestamp=timestamp)
121
+
122
+
123
+ def get_trace_id() -> str:
124
+ current_span = trace.get_current_span()
125
+ if current_span:
126
+ return format(current_span.get_span_context().trace_id, "032x")
127
+ else:
128
+ return ""
129
+
130
+
131
+ def request_hook(span: Span, _request_context: Optional[Dict] = None, response: Optional[Any] = None):
132
+ """Hook to update span based on response status code"""
133
+ if response is not None:
134
+ if hasattr(response, "status_code"):
135
+ span.set_attribute("http.status_code", response.status_code)
136
+ if response.status_code >= 400:
137
+ span.set_status(Status(StatusCode.ERROR))
138
+ elif 200 <= response.status_code < 300:
139
+ span.set_status(Status(StatusCode.OK))
140
+
141
+
142
+ def setup_tracing(endpoint: str, service_name: str = "memgpt-server") -> None:
143
+ """
144
+ Sets up OpenTelemetry tracing with OTLP exporter for specific endpoints
145
+
146
+ Args:
147
+ endpoint: OTLP endpoint URL
148
+ service_name: Name of the service for tracing
149
+ """
150
+ global _is_tracing_initialized
151
+
152
+ # Skip tracing in pytest environment
153
+ if is_pytest_environment():
154
+ print("ℹ️ Skipping tracing setup in pytest environment")
155
+ return
156
+
157
+ # Create a Resource to identify our service
158
+ resource = Resource.create({"service.name": service_name, "service.namespace": "default", "deployment.environment": "production"})
159
+
160
+ # Initialize the TracerProvider with the resource
161
+ provider = TracerProvider(resource=resource)
162
+
163
+ # Only set up OTLP export if endpoint is provided
164
+ if endpoint:
165
+ otlp_exporter = OTLPSpanExporter(endpoint=endpoint)
166
+ processor = BatchSpanProcessor(otlp_exporter)
167
+ provider.add_span_processor(processor)
168
+ _is_tracing_initialized = True
169
+ else:
170
+ print("⚠️ Warning: Tracing endpoint not provided, tracing will be disabled")
171
+
172
+ # Set the global TracerProvider
173
+ trace.set_tracer_provider(provider)
174
+
175
+ # Initialize automatic instrumentation for the requests library with response hook
176
+ if _is_tracing_initialized:
177
+ RequestsInstrumentor().instrument(response_hook=request_hook)
178
+
179
+
180
+ def _extract_request_info(args: tuple, span: Span) -> Dict[str, Any]:
181
+ """
182
+ Safely extracts request information from function arguments.
183
+ Works with both FastAPI route handlers and inner functions.
184
+ """
185
+ attributes = {}
186
+
187
+ # Look for FastAPI Request object in args
188
+ request = next((arg for arg in args if isinstance(arg, Request)), None)
189
+
190
+ if request:
191
+ attributes.update(
192
+ {
193
+ "http.route": request.url.path,
194
+ "http.method": request.method,
195
+ "http.scheme": request.url.scheme,
196
+ "http.target": str(request.url.path),
197
+ "http.url": str(request.url),
198
+ "http.flavor": request.scope.get("http_version", ""),
199
+ "http.client_ip": request.client.host if request.client else None,
200
+ "http.user_id": request.headers.get("user_id"),
201
+ }
202
+ )
203
+
204
+ span.set_attributes(attributes)
205
+ return attributes
letta/utils.py CHANGED
@@ -824,12 +824,16 @@ def parse_json(string) -> dict:
824
824
  result = None
825
825
  try:
826
826
  result = json_loads(string)
827
+ if not isinstance(result, dict):
828
+ raise ValueError(f"JSON from string input ({string}) is not a dictionary (type {type(result)}): {result}")
827
829
  return result
828
830
  except Exception as e:
829
831
  print(f"Error parsing json with json package: {e}")
830
832
 
831
833
  try:
832
834
  result = demjson.decode(string)
835
+ if not isinstance(result, dict):
836
+ raise ValueError(f"JSON from string input ({string}) is not a dictionary (type {type(result)}): {result}")
833
837
  return result
834
838
  except demjson.JSONDecodeError as e:
835
839
  print(f"Error parsing json with demjson package: {e}")