letta-nightly 0.6.27.dev20250219104103__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.
- letta/__init__.py +1 -1
- letta/agent.py +13 -1
- letta/client/client.py +2 -0
- letta/constants.py +2 -0
- letta/functions/schema_generator.py +6 -6
- letta/helpers/converters.py +153 -0
- letta/helpers/tool_rule_solver.py +11 -1
- letta/llm_api/anthropic.py +10 -5
- letta/llm_api/aws_bedrock.py +1 -1
- letta/llm_api/azure_openai_constants.py +1 -0
- letta/llm_api/deepseek.py +303 -0
- letta/llm_api/llm_api_tools.py +81 -1
- letta/llm_api/openai.py +13 -0
- letta/local_llm/chat_completion_proxy.py +15 -2
- letta/local_llm/lmstudio/api.py +75 -1
- letta/orm/__init__.py +1 -0
- letta/orm/agent.py +14 -5
- letta/orm/custom_columns.py +31 -110
- letta/orm/identity.py +39 -0
- letta/orm/organization.py +2 -0
- letta/schemas/agent.py +13 -1
- letta/schemas/identity.py +44 -0
- letta/schemas/llm_config.py +2 -0
- letta/schemas/message.py +1 -1
- letta/schemas/openai/chat_completion_response.py +2 -0
- letta/schemas/providers.py +72 -1
- letta/schemas/tool_rule.py +9 -1
- letta/serialize_schemas/__init__.py +1 -0
- letta/serialize_schemas/agent.py +36 -0
- letta/serialize_schemas/base.py +12 -0
- letta/serialize_schemas/custom_fields.py +69 -0
- letta/serialize_schemas/message.py +15 -0
- letta/server/db.py +111 -0
- letta/server/rest_api/app.py +8 -0
- letta/server/rest_api/interface.py +114 -9
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +7 -1
- letta/server/rest_api/routers/v1/identities.py +111 -0
- letta/server/server.py +13 -116
- letta/services/agent_manager.py +54 -6
- letta/services/block_manager.py +1 -1
- letta/services/helpers/agent_manager_helper.py +15 -0
- letta/services/identity_manager.py +140 -0
- letta/services/job_manager.py +1 -1
- letta/services/message_manager.py +1 -1
- letta/services/organization_manager.py +1 -1
- letta/services/passage_manager.py +1 -1
- letta/services/provider_manager.py +1 -1
- letta/services/sandbox_config_manager.py +1 -1
- letta/services/source_manager.py +1 -1
- letta/services/step_manager.py +1 -1
- letta/services/tool_manager.py +1 -1
- letta/services/user_manager.py +1 -1
- letta/settings.py +3 -0
- letta/tracing.py +205 -0
- letta/utils.py +4 -0
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/METADATA +9 -2
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/RECORD +61 -48
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/LICENSE +0 -0
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/WHEEL +0 -0
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/entry_points.txt +0 -0
letta/services/agent_manager.py
CHANGED
|
@@ -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.
|
|
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=
|
|
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
|
# ======================================================================================================================
|
letta/services/block_manager.py
CHANGED
|
@@ -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)
|
letta/services/job_manager.py
CHANGED
|
@@ -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.
|
|
19
|
+
from letta.server.db import db_context
|
|
20
20
|
|
|
21
21
|
self.session_maker = db_context
|
|
22
22
|
|
|
@@ -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.
|
|
23
|
+
from letta.server.db import db_context
|
|
24
24
|
|
|
25
25
|
self.session_maker = db_context
|
|
26
26
|
|
letta/services/source_manager.py
CHANGED
letta/services/step_manager.py
CHANGED
letta/services/tool_manager.py
CHANGED
letta/services/user_manager.py
CHANGED
letta/settings.py
CHANGED
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}")
|