letta-nightly 0.5.4.dev20241122104229__py3-none-any.whl → 0.5.4.dev20241123104112__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/agent.py +23 -3
- letta/agent_store/db.py +1 -1
- letta/client/client.py +290 -0
- letta/constants.py +5 -14
- letta/functions/helpers.py +0 -4
- letta/functions/schema_generator.py +24 -4
- letta/local_llm/utils.py +6 -3
- letta/log.py +7 -9
- letta/orm/__init__.py +2 -0
- letta/orm/block.py +5 -2
- letta/orm/blocks_agents.py +29 -0
- letta/orm/mixins.py +8 -0
- letta/orm/organization.py +8 -1
- letta/orm/sandbox_config.py +56 -0
- letta/orm/sqlalchemy_base.py +9 -3
- letta/schemas/blocks_agents.py +32 -0
- letta/schemas/letta_base.py +9 -0
- letta/schemas/memory.py +9 -8
- letta/schemas/sandbox_config.py +114 -0
- letta/server/rest_api/routers/v1/__init__.py +4 -9
- letta/server/rest_api/routers/v1/sandbox_configs.py +108 -0
- letta/server/rest_api/routers/v1/tools.py +3 -5
- letta/server/rest_api/utils.py +6 -0
- letta/server/server.py +10 -5
- letta/services/block_manager.py +4 -2
- letta/services/blocks_agents_manager.py +84 -0
- letta/services/sandbox_config_manager.py +256 -0
- letta/services/tool_execution_sandbox.py +326 -0
- letta/services/tool_manager.py +10 -10
- letta/services/tool_sandbox_env/.gitkeep +0 -0
- letta/settings.py +4 -0
- {letta_nightly-0.5.4.dev20241122104229.dist-info → letta_nightly-0.5.4.dev20241123104112.dist-info}/METADATA +3 -1
- {letta_nightly-0.5.4.dev20241122104229.dist-info → letta_nightly-0.5.4.dev20241123104112.dist-info}/RECORD +36 -27
- {letta_nightly-0.5.4.dev20241122104229.dist-info → letta_nightly-0.5.4.dev20241123104112.dist-info}/LICENSE +0 -0
- {letta_nightly-0.5.4.dev20241122104229.dist-info → letta_nightly-0.5.4.dev20241123104112.dist-info}/WHEEL +0 -0
- {letta_nightly-0.5.4.dev20241122104229.dist-info → letta_nightly-0.5.4.dev20241123104112.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from letta.orm.blocks_agents import BlocksAgents as BlocksAgentsModel
|
|
5
|
+
from letta.orm.errors import NoResultFound
|
|
6
|
+
from letta.schemas.blocks_agents import BlocksAgents as PydanticBlocksAgents
|
|
7
|
+
from letta.utils import enforce_types
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# TODO: DELETE THIS ASAP
|
|
11
|
+
# TODO: So we have a patch where we manually specify CRUD operations
|
|
12
|
+
# TODO: This is because Agent is NOT migrated to the ORM yet
|
|
13
|
+
# TODO: Once we migrate Agent to the ORM, we should deprecate any agents relationship table managers
|
|
14
|
+
class BlocksAgentsManager:
|
|
15
|
+
"""Manager class to handle business logic related to Blocks and Agents."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
from letta.server.server import db_context
|
|
19
|
+
|
|
20
|
+
self.session_maker = db_context
|
|
21
|
+
|
|
22
|
+
@enforce_types
|
|
23
|
+
def add_block_to_agent(self, agent_id: str, block_id: str, block_label: str) -> PydanticBlocksAgents:
|
|
24
|
+
"""Add a block to an agent. If the label already exists on that agent, this will error."""
|
|
25
|
+
with self.session_maker() as session:
|
|
26
|
+
try:
|
|
27
|
+
# Check if the block-label combination already exists for this agent
|
|
28
|
+
blocks_agents_record = BlocksAgentsModel.read(db_session=session, agent_id=agent_id, block_label=block_label)
|
|
29
|
+
warnings.warn(f"Block label '{block_label}' already exists for agent '{agent_id}'.")
|
|
30
|
+
except NoResultFound:
|
|
31
|
+
blocks_agents_record = PydanticBlocksAgents(agent_id=agent_id, block_id=block_id, block_label=block_label)
|
|
32
|
+
blocks_agents_record = BlocksAgentsModel(**blocks_agents_record.model_dump(exclude_none=True))
|
|
33
|
+
blocks_agents_record.create(session)
|
|
34
|
+
|
|
35
|
+
return blocks_agents_record.to_pydantic()
|
|
36
|
+
|
|
37
|
+
@enforce_types
|
|
38
|
+
def remove_block_with_label_from_agent(self, agent_id: str, block_label: str) -> PydanticBlocksAgents:
|
|
39
|
+
"""Remove a block with a label from an agent."""
|
|
40
|
+
with self.session_maker() as session:
|
|
41
|
+
try:
|
|
42
|
+
# Find and delete the block-label association for the agent
|
|
43
|
+
blocks_agents_record = BlocksAgentsModel.read(db_session=session, agent_id=agent_id, block_label=block_label)
|
|
44
|
+
blocks_agents_record.hard_delete(session)
|
|
45
|
+
return blocks_agents_record.to_pydantic()
|
|
46
|
+
except NoResultFound:
|
|
47
|
+
raise ValueError(f"Block label '{block_label}' not found for agent '{agent_id}'.")
|
|
48
|
+
|
|
49
|
+
@enforce_types
|
|
50
|
+
def remove_block_with_id_from_agent(self, agent_id: str, block_id: str) -> PydanticBlocksAgents:
|
|
51
|
+
"""Remove a block with a label from an agent."""
|
|
52
|
+
with self.session_maker() as session:
|
|
53
|
+
try:
|
|
54
|
+
# Find and delete the block-label association for the agent
|
|
55
|
+
blocks_agents_record = BlocksAgentsModel.read(db_session=session, agent_id=agent_id, block_id=block_id)
|
|
56
|
+
blocks_agents_record.hard_delete(session)
|
|
57
|
+
return blocks_agents_record.to_pydantic()
|
|
58
|
+
except NoResultFound:
|
|
59
|
+
raise ValueError(f"Block id '{block_id}' not found for agent '{agent_id}'.")
|
|
60
|
+
|
|
61
|
+
@enforce_types
|
|
62
|
+
def update_block_id_for_agent(self, agent_id: str, block_label: str, new_block_id: str) -> PydanticBlocksAgents:
|
|
63
|
+
"""Update the block ID for a specific block label for an agent."""
|
|
64
|
+
with self.session_maker() as session:
|
|
65
|
+
try:
|
|
66
|
+
blocks_agents_record = BlocksAgentsModel.read(db_session=session, agent_id=agent_id, block_label=block_label)
|
|
67
|
+
blocks_agents_record.block_id = new_block_id
|
|
68
|
+
return blocks_agents_record.to_pydantic()
|
|
69
|
+
except NoResultFound:
|
|
70
|
+
raise ValueError(f"Block label '{block_label}' not found for agent '{agent_id}'.")
|
|
71
|
+
|
|
72
|
+
@enforce_types
|
|
73
|
+
def list_block_ids_for_agent(self, agent_id: str) -> List[str]:
|
|
74
|
+
"""List all blocks associated with a specific agent."""
|
|
75
|
+
with self.session_maker() as session:
|
|
76
|
+
blocks_agents_record = BlocksAgentsModel.list(db_session=session, agent_id=agent_id)
|
|
77
|
+
return [record.block_id for record in blocks_agents_record]
|
|
78
|
+
|
|
79
|
+
@enforce_types
|
|
80
|
+
def list_agent_ids_with_block(self, block_id: str) -> List[str]:
|
|
81
|
+
"""List all agents associated with a specific block."""
|
|
82
|
+
with self.session_maker() as session:
|
|
83
|
+
blocks_agents_record = BlocksAgentsModel.list(db_session=session, block_id=block_id)
|
|
84
|
+
return [record.agent_id for record in blocks_agents_record]
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from letta.log import get_logger
|
|
5
|
+
from letta.orm.errors import NoResultFound
|
|
6
|
+
from letta.orm.sandbox_config import SandboxConfig as SandboxConfigModel
|
|
7
|
+
from letta.orm.sandbox_config import SandboxEnvironmentVariable as SandboxEnvVarModel
|
|
8
|
+
from letta.schemas.sandbox_config import E2BSandboxConfig, LocalSandboxConfig
|
|
9
|
+
from letta.schemas.sandbox_config import SandboxConfig as PydanticSandboxConfig
|
|
10
|
+
from letta.schemas.sandbox_config import SandboxConfigCreate, SandboxConfigUpdate
|
|
11
|
+
from letta.schemas.sandbox_config import SandboxEnvironmentVariable as PydanticEnvVar
|
|
12
|
+
from letta.schemas.sandbox_config import (
|
|
13
|
+
SandboxEnvironmentVariableCreate,
|
|
14
|
+
SandboxEnvironmentVariableUpdate,
|
|
15
|
+
SandboxType,
|
|
16
|
+
)
|
|
17
|
+
from letta.schemas.user import User as PydanticUser
|
|
18
|
+
from letta.utils import enforce_types, printd
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SandboxConfigManager:
|
|
24
|
+
"""Manager class to handle business logic related to SandboxConfig and SandboxEnvironmentVariable."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, settings):
|
|
27
|
+
from letta.server.server import db_context
|
|
28
|
+
|
|
29
|
+
self.session_maker = db_context
|
|
30
|
+
self.e2b_template_id = settings.e2b_sandbox_template_id
|
|
31
|
+
|
|
32
|
+
@enforce_types
|
|
33
|
+
def get_or_create_default_sandbox_config(self, sandbox_type: SandboxType, actor: PydanticUser) -> PydanticSandboxConfig:
|
|
34
|
+
sandbox_config = self.get_sandbox_config_by_type(sandbox_type, actor=actor)
|
|
35
|
+
if not sandbox_config:
|
|
36
|
+
logger.info(f"Creating new sandbox config of type {sandbox_type}, none found for organization {actor.organization_id}.")
|
|
37
|
+
|
|
38
|
+
# TODO: Add more sandbox types later
|
|
39
|
+
if sandbox_type == SandboxType.E2B:
|
|
40
|
+
default_config = E2BSandboxConfig(template=self.e2b_template_id).model_dump(exclude_none=True)
|
|
41
|
+
else:
|
|
42
|
+
default_local_sandbox_path = str(Path(__file__).parent / "tool_sandbox_env")
|
|
43
|
+
default_config = LocalSandboxConfig(sandbox_dir=default_local_sandbox_path).model_dump(exclude_none=True)
|
|
44
|
+
|
|
45
|
+
sandbox_config = self.create_or_update_sandbox_config(SandboxConfigCreate(config=default_config), actor=actor)
|
|
46
|
+
return sandbox_config
|
|
47
|
+
|
|
48
|
+
@enforce_types
|
|
49
|
+
def create_or_update_sandbox_config(self, sandbox_config_create: SandboxConfigCreate, actor: PydanticUser) -> PydanticSandboxConfig:
|
|
50
|
+
"""Create or update a sandbox configuration based on the PydanticSandboxConfig schema."""
|
|
51
|
+
config = sandbox_config_create.config
|
|
52
|
+
sandbox_type = config.type
|
|
53
|
+
sandbox_config = PydanticSandboxConfig(
|
|
54
|
+
type=sandbox_type, config=config.model_dump(exclude_none=True), organization_id=actor.organization_id
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Attempt to retrieve the existing sandbox configuration by type within the organization
|
|
58
|
+
db_sandbox = self.get_sandbox_config_by_type(sandbox_config.type, actor=actor)
|
|
59
|
+
if db_sandbox:
|
|
60
|
+
# Prepare the update data, excluding fields that should not be reset
|
|
61
|
+
update_data = sandbox_config.model_dump(exclude_unset=True, exclude_none=True)
|
|
62
|
+
update_data = {key: value for key, value in update_data.items() if getattr(db_sandbox, key) != value}
|
|
63
|
+
|
|
64
|
+
# If there are changes, update the sandbox configuration
|
|
65
|
+
if update_data:
|
|
66
|
+
db_sandbox = self.update_sandbox_config(db_sandbox.id, SandboxConfigUpdate(**update_data), actor)
|
|
67
|
+
else:
|
|
68
|
+
printd(
|
|
69
|
+
f"`create_or_update_sandbox_config` was called with user_id={actor.id}, organization_id={actor.organization_id}, "
|
|
70
|
+
f"type={sandbox_config.type}, but found existing configuration with nothing to update."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return db_sandbox
|
|
74
|
+
else:
|
|
75
|
+
# If the sandbox configuration doesn't exist, create a new one
|
|
76
|
+
with self.session_maker() as session:
|
|
77
|
+
db_sandbox = SandboxConfigModel(**sandbox_config.model_dump(exclude_none=True))
|
|
78
|
+
db_sandbox.create(session, actor=actor)
|
|
79
|
+
return db_sandbox.to_pydantic()
|
|
80
|
+
|
|
81
|
+
@enforce_types
|
|
82
|
+
def update_sandbox_config(
|
|
83
|
+
self, sandbox_config_id: str, sandbox_update: SandboxConfigUpdate, actor: PydanticUser
|
|
84
|
+
) -> PydanticSandboxConfig:
|
|
85
|
+
"""Update an existing sandbox configuration."""
|
|
86
|
+
with self.session_maker() as session:
|
|
87
|
+
sandbox = SandboxConfigModel.read(db_session=session, identifier=sandbox_config_id, actor=actor)
|
|
88
|
+
# We need to check that the sandbox_update provided is the same type as the original sandbox
|
|
89
|
+
if sandbox.type != sandbox_update.config.type:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"Mismatched type for sandbox config update: tried to update sandbox_config of type {sandbox.type} with config of type {sandbox_update.config.type}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
update_data = sandbox_update.model_dump(exclude_unset=True, exclude_none=True)
|
|
95
|
+
update_data = {key: value for key, value in update_data.items() if getattr(sandbox, key) != value}
|
|
96
|
+
|
|
97
|
+
if update_data:
|
|
98
|
+
for key, value in update_data.items():
|
|
99
|
+
setattr(sandbox, key, value)
|
|
100
|
+
sandbox.update(db_session=session, actor=actor)
|
|
101
|
+
else:
|
|
102
|
+
printd(
|
|
103
|
+
f"`update_sandbox_config` called with user_id={actor.id}, organization_id={actor.organization_id}, "
|
|
104
|
+
f"name={sandbox.type}, but nothing to update."
|
|
105
|
+
)
|
|
106
|
+
return sandbox.to_pydantic()
|
|
107
|
+
|
|
108
|
+
@enforce_types
|
|
109
|
+
def delete_sandbox_config(self, sandbox_config_id: str, actor: PydanticUser) -> PydanticSandboxConfig:
|
|
110
|
+
"""Delete a sandbox configuration by its ID."""
|
|
111
|
+
with self.session_maker() as session:
|
|
112
|
+
sandbox = SandboxConfigModel.read(db_session=session, identifier=sandbox_config_id, actor=actor)
|
|
113
|
+
sandbox.hard_delete(db_session=session, actor=actor)
|
|
114
|
+
return sandbox.to_pydantic()
|
|
115
|
+
|
|
116
|
+
@enforce_types
|
|
117
|
+
def list_sandbox_configs(
|
|
118
|
+
self, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50
|
|
119
|
+
) -> List[PydanticSandboxConfig]:
|
|
120
|
+
"""List all sandbox configurations with optional pagination."""
|
|
121
|
+
with self.session_maker() as session:
|
|
122
|
+
sandboxes = SandboxConfigModel.list(
|
|
123
|
+
db_session=session,
|
|
124
|
+
cursor=cursor,
|
|
125
|
+
limit=limit,
|
|
126
|
+
organization_id=actor.organization_id,
|
|
127
|
+
)
|
|
128
|
+
return [sandbox.to_pydantic() for sandbox in sandboxes]
|
|
129
|
+
|
|
130
|
+
@enforce_types
|
|
131
|
+
def get_sandbox_config_by_id(self, sandbox_config_id: str, actor: Optional[PydanticUser] = None) -> Optional[PydanticSandboxConfig]:
|
|
132
|
+
"""Retrieve a sandbox configuration by its ID."""
|
|
133
|
+
with self.session_maker() as session:
|
|
134
|
+
try:
|
|
135
|
+
sandbox = SandboxConfigModel.read(db_session=session, identifier=sandbox_config_id, actor=actor)
|
|
136
|
+
return sandbox.to_pydantic()
|
|
137
|
+
except NoResultFound:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
@enforce_types
|
|
141
|
+
def get_sandbox_config_by_type(self, type: SandboxType, actor: Optional[PydanticUser] = None) -> Optional[PydanticSandboxConfig]:
|
|
142
|
+
"""Retrieve a sandbox config by its type."""
|
|
143
|
+
with self.session_maker() as session:
|
|
144
|
+
try:
|
|
145
|
+
sandboxes = SandboxConfigModel.list(
|
|
146
|
+
db_session=session,
|
|
147
|
+
type=type,
|
|
148
|
+
organization_id=actor.organization_id,
|
|
149
|
+
limit=1,
|
|
150
|
+
)
|
|
151
|
+
if sandboxes:
|
|
152
|
+
return sandboxes[0].to_pydantic()
|
|
153
|
+
return None
|
|
154
|
+
except NoResultFound:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
@enforce_types
|
|
158
|
+
def create_sandbox_env_var(
|
|
159
|
+
self, env_var_create: SandboxEnvironmentVariableCreate, sandbox_config_id: str, actor: PydanticUser
|
|
160
|
+
) -> PydanticEnvVar:
|
|
161
|
+
"""Create a new sandbox environment variable."""
|
|
162
|
+
env_var = PydanticEnvVar(**env_var_create.model_dump(), sandbox_config_id=sandbox_config_id, organization_id=actor.organization_id)
|
|
163
|
+
|
|
164
|
+
db_env_var = self.get_sandbox_env_var_by_key_and_sandbox_config_id(env_var.key, env_var.sandbox_config_id, actor=actor)
|
|
165
|
+
if db_env_var:
|
|
166
|
+
update_data = env_var.model_dump(exclude_unset=True, exclude_none=True)
|
|
167
|
+
update_data = {key: value for key, value in update_data.items() if getattr(db_env_var, key) != value}
|
|
168
|
+
# If there are changes, update the environment variable
|
|
169
|
+
if update_data:
|
|
170
|
+
db_env_var = self.update_sandbox_env_var(db_env_var.id, SandboxEnvironmentVariableUpdate(**update_data), actor)
|
|
171
|
+
else:
|
|
172
|
+
printd(
|
|
173
|
+
f"`create_or_update_sandbox_env_var` was called with user_id={actor.id}, organization_id={actor.organization_id}, "
|
|
174
|
+
f"key={env_var.key}, but found existing variable with nothing to update."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return db_env_var
|
|
178
|
+
else:
|
|
179
|
+
with self.session_maker() as session:
|
|
180
|
+
env_var = SandboxEnvVarModel(**env_var.model_dump(exclude_none=True))
|
|
181
|
+
env_var.create(session, actor=actor)
|
|
182
|
+
return env_var.to_pydantic()
|
|
183
|
+
|
|
184
|
+
@enforce_types
|
|
185
|
+
def update_sandbox_env_var(
|
|
186
|
+
self, env_var_id: str, env_var_update: SandboxEnvironmentVariableUpdate, actor: PydanticUser
|
|
187
|
+
) -> PydanticEnvVar:
|
|
188
|
+
"""Update an existing sandbox environment variable."""
|
|
189
|
+
with self.session_maker() as session:
|
|
190
|
+
env_var = SandboxEnvVarModel.read(db_session=session, identifier=env_var_id, actor=actor)
|
|
191
|
+
update_data = env_var_update.model_dump(exclude_unset=True, exclude_none=True)
|
|
192
|
+
update_data = {key: value for key, value in update_data.items() if getattr(env_var, key) != value}
|
|
193
|
+
|
|
194
|
+
if update_data:
|
|
195
|
+
for key, value in update_data.items():
|
|
196
|
+
setattr(env_var, key, value)
|
|
197
|
+
env_var.update(db_session=session, actor=actor)
|
|
198
|
+
else:
|
|
199
|
+
printd(
|
|
200
|
+
f"`update_sandbox_env_var` called with user_id={actor.id}, organization_id={actor.organization_id}, "
|
|
201
|
+
f"key={env_var.key}, but nothing to update."
|
|
202
|
+
)
|
|
203
|
+
return env_var.to_pydantic()
|
|
204
|
+
|
|
205
|
+
@enforce_types
|
|
206
|
+
def delete_sandbox_env_var(self, env_var_id: str, actor: PydanticUser) -> PydanticEnvVar:
|
|
207
|
+
"""Delete a sandbox environment variable by its ID."""
|
|
208
|
+
with self.session_maker() as session:
|
|
209
|
+
env_var = SandboxEnvVarModel.read(db_session=session, identifier=env_var_id, actor=actor)
|
|
210
|
+
env_var.hard_delete(db_session=session, actor=actor)
|
|
211
|
+
return env_var.to_pydantic()
|
|
212
|
+
|
|
213
|
+
@enforce_types
|
|
214
|
+
def list_sandbox_env_vars(
|
|
215
|
+
self, sandbox_config_id: str, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50
|
|
216
|
+
) -> List[PydanticEnvVar]:
|
|
217
|
+
"""List all sandbox environment variables with optional pagination."""
|
|
218
|
+
with self.session_maker() as session:
|
|
219
|
+
env_vars = SandboxEnvVarModel.list(
|
|
220
|
+
db_session=session,
|
|
221
|
+
cursor=cursor,
|
|
222
|
+
limit=limit,
|
|
223
|
+
organization_id=actor.organization_id,
|
|
224
|
+
sandbox_config_id=sandbox_config_id,
|
|
225
|
+
)
|
|
226
|
+
return [env_var.to_pydantic() for env_var in env_vars]
|
|
227
|
+
|
|
228
|
+
@enforce_types
|
|
229
|
+
def get_sandbox_env_vars_as_dict(
|
|
230
|
+
self, sandbox_config_id: str, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50
|
|
231
|
+
) -> Dict[str, str]:
|
|
232
|
+
env_vars = self.list_sandbox_env_vars(sandbox_config_id, actor, cursor, limit)
|
|
233
|
+
result = {}
|
|
234
|
+
for env_var in env_vars:
|
|
235
|
+
result[env_var.key] = env_var.value
|
|
236
|
+
return result
|
|
237
|
+
|
|
238
|
+
@enforce_types
|
|
239
|
+
def get_sandbox_env_var_by_key_and_sandbox_config_id(
|
|
240
|
+
self, key: str, sandbox_config_id: str, actor: Optional[PydanticUser] = None
|
|
241
|
+
) -> Optional[PydanticEnvVar]:
|
|
242
|
+
"""Retrieve a sandbox environment variable by its key and sandbox_config_id."""
|
|
243
|
+
with self.session_maker() as session:
|
|
244
|
+
try:
|
|
245
|
+
env_var = SandboxEnvVarModel.list(
|
|
246
|
+
db_session=session,
|
|
247
|
+
key=key,
|
|
248
|
+
sandbox_config_id=sandbox_config_id,
|
|
249
|
+
organization_id=actor.organization_id,
|
|
250
|
+
limit=1,
|
|
251
|
+
)
|
|
252
|
+
if env_var:
|
|
253
|
+
return env_var[0].to_pydantic()
|
|
254
|
+
return None
|
|
255
|
+
except NoResultFound:
|
|
256
|
+
return None
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import base64
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import pickle
|
|
6
|
+
import runpy
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from letta.log import get_logger
|
|
12
|
+
from letta.schemas.agent import AgentState
|
|
13
|
+
from letta.schemas.sandbox_config import SandboxConfig, SandboxRunResult, SandboxType
|
|
14
|
+
from letta.services.sandbox_config_manager import SandboxConfigManager
|
|
15
|
+
from letta.services.tool_manager import ToolManager
|
|
16
|
+
from letta.services.user_manager import UserManager
|
|
17
|
+
from letta.settings import tool_settings
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolExecutionSandbox:
|
|
23
|
+
METADATA_CONFIG_STATE_KEY = "config_state"
|
|
24
|
+
REQUIREMENT_TXT_NAME = "requirements.txt"
|
|
25
|
+
|
|
26
|
+
# This is the variable name in the auto-generated code that contains the function results
|
|
27
|
+
# We make this a long random string to avoid collisions with any variables in the user's code
|
|
28
|
+
LOCAL_SANDBOX_RESULT_VAR_NAME = "result_ZQqiequkcFwRwwGQMqkt"
|
|
29
|
+
|
|
30
|
+
def __init__(self, tool_name: str, args: dict, user_id: str, force_recreate=False):
|
|
31
|
+
self.tool_name = tool_name
|
|
32
|
+
self.args = args
|
|
33
|
+
|
|
34
|
+
# Get the user
|
|
35
|
+
# This user corresponds to the agent_state's user_id field
|
|
36
|
+
# agent_state is the state of the agent that invoked this run
|
|
37
|
+
self.user = UserManager().get_user_by_id(user_id=user_id)
|
|
38
|
+
|
|
39
|
+
# Get the tool
|
|
40
|
+
# TODO: So in theory, it's possible this retrieves a tool not provisioned to the agent
|
|
41
|
+
# TODO: That would probably imply that agent_state is incorrectly configured
|
|
42
|
+
self.tool = ToolManager().get_tool_by_name(tool_name=tool_name, actor=self.user)
|
|
43
|
+
if not self.tool:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Agent attempted to invoke tool {self.tool_name} that does not exist for organization {self.user.organization_id}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self.sandbox_config_manager = SandboxConfigManager(tool_settings)
|
|
49
|
+
self.force_recreate = force_recreate
|
|
50
|
+
|
|
51
|
+
def run(self, agent_state: Optional[AgentState] = None) -> Optional[SandboxRunResult]:
|
|
52
|
+
"""
|
|
53
|
+
Run the tool in a sandbox environment.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
agent_state (Optional[AgentState]): The state of the agent invoking the tool
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Tuple[Any, Optional[AgentState]]: Tuple containing (tool_result, agent_state)
|
|
60
|
+
"""
|
|
61
|
+
if tool_settings.e2b_api_key:
|
|
62
|
+
logger.info(f"Using e2b sandbox to execute {self.tool_name}")
|
|
63
|
+
code = self.generate_execution_script(agent_state=agent_state)
|
|
64
|
+
result = self.run_e2b_sandbox(code=code)
|
|
65
|
+
else:
|
|
66
|
+
logger.info(f"Using local sandbox to execute {self.tool_name}")
|
|
67
|
+
code = self.generate_execution_script(agent_state=agent_state)
|
|
68
|
+
result = self.run_local_dir_sandbox(code=code)
|
|
69
|
+
|
|
70
|
+
# Log out any stdout from the tool run
|
|
71
|
+
logger.info(f"Executed tool '{self.tool_name}', logging stdout from tool run: \n")
|
|
72
|
+
for log_line in result.stdout:
|
|
73
|
+
logger.info(f"{log_line}")
|
|
74
|
+
logger.info(f"Ending stdout log from tool run.")
|
|
75
|
+
|
|
76
|
+
# Return result
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
# local sandbox specific functions
|
|
80
|
+
from contextlib import contextmanager
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def temporary_env_vars(self, env_vars: dict):
|
|
84
|
+
original_env = os.environ.copy() # Backup original environment variables
|
|
85
|
+
os.environ.update(env_vars) # Update with the new variables
|
|
86
|
+
try:
|
|
87
|
+
yield
|
|
88
|
+
finally:
|
|
89
|
+
os.environ.clear()
|
|
90
|
+
os.environ.update(original_env) # Restore original environment variables
|
|
91
|
+
|
|
92
|
+
def run_local_dir_sandbox(self, code: str) -> Optional[SandboxRunResult]:
|
|
93
|
+
sbx_config = self.sandbox_config_manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.LOCAL, actor=self.user)
|
|
94
|
+
local_configs = sbx_config.get_local_config()
|
|
95
|
+
|
|
96
|
+
# Get environment variables for the sandbox
|
|
97
|
+
env_vars = self.sandbox_config_manager.get_sandbox_env_vars_as_dict(sandbox_config_id=sbx_config.id, actor=self.user, limit=100)
|
|
98
|
+
|
|
99
|
+
# Safety checks
|
|
100
|
+
if not os.path.isdir(local_configs.sandbox_dir):
|
|
101
|
+
raise FileNotFoundError(f"Sandbox directory does not exist: {local_configs.sandbox_dir}")
|
|
102
|
+
|
|
103
|
+
# Write the code to a temp file in the sandbox_dir
|
|
104
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=local_configs.sandbox_dir, suffix=".py", delete=False) as temp_file:
|
|
105
|
+
temp_file.write(code)
|
|
106
|
+
temp_file.flush()
|
|
107
|
+
temp_file_path = temp_file.name
|
|
108
|
+
|
|
109
|
+
# Save the old stdout
|
|
110
|
+
old_stdout = sys.stdout
|
|
111
|
+
try:
|
|
112
|
+
# Redirect stdout to capture script output
|
|
113
|
+
captured_stdout = io.StringIO()
|
|
114
|
+
sys.stdout = captured_stdout
|
|
115
|
+
|
|
116
|
+
# Execute the temp file
|
|
117
|
+
with self.temporary_env_vars(env_vars):
|
|
118
|
+
result = runpy.run_path(temp_file_path, init_globals=env_vars)
|
|
119
|
+
|
|
120
|
+
# Fetch the result
|
|
121
|
+
func_result = result.get(self.LOCAL_SANDBOX_RESULT_VAR_NAME)
|
|
122
|
+
func_return, agent_state = self.parse_best_effort(func_result)
|
|
123
|
+
|
|
124
|
+
# Restore stdout and collect captured output
|
|
125
|
+
sys.stdout = old_stdout
|
|
126
|
+
stdout_output = captured_stdout.getvalue()
|
|
127
|
+
|
|
128
|
+
return SandboxRunResult(
|
|
129
|
+
func_return=func_return,
|
|
130
|
+
agent_state=agent_state,
|
|
131
|
+
stdout=[stdout_output],
|
|
132
|
+
sandbox_config_fingerprint=sbx_config.fingerprint(),
|
|
133
|
+
)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
raise RuntimeError(f"Executing tool {self.tool_name} has an unexpected error: {e}")
|
|
136
|
+
finally:
|
|
137
|
+
# Clean up the temp file and restore stdout
|
|
138
|
+
sys.stdout = old_stdout
|
|
139
|
+
os.remove(temp_file_path)
|
|
140
|
+
|
|
141
|
+
# e2b sandbox specific functions
|
|
142
|
+
|
|
143
|
+
def run_e2b_sandbox(self, code: str) -> Optional[SandboxRunResult]:
|
|
144
|
+
sbx_config = self.sandbox_config_manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.E2B, actor=self.user)
|
|
145
|
+
sbx = self.get_running_e2b_sandbox_with_same_state(sbx_config)
|
|
146
|
+
if not sbx or self.force_recreate:
|
|
147
|
+
sbx = self.create_e2b_sandbox_with_metadata_hash(sandbox_config=sbx_config)
|
|
148
|
+
|
|
149
|
+
# Since this sandbox was used, we extend its lifecycle by the timeout
|
|
150
|
+
sbx.set_timeout(sbx_config.get_e2b_config().timeout)
|
|
151
|
+
|
|
152
|
+
# Get environment variables for the sandbox
|
|
153
|
+
# TODO: We set limit to 100 here, but maybe we want it uncapped? Realistically this should be fine.
|
|
154
|
+
env_vars = self.sandbox_config_manager.get_sandbox_env_vars_as_dict(sandbox_config_id=sbx_config.id, actor=self.user, limit=100)
|
|
155
|
+
execution = sbx.run_code(code, envs=env_vars)
|
|
156
|
+
if execution.error is not None:
|
|
157
|
+
raise Exception(f"Executing tool {self.tool_name} failed with {execution.error}. Generated code: \n\n{code}")
|
|
158
|
+
elif len(execution.results) == 0:
|
|
159
|
+
return None
|
|
160
|
+
else:
|
|
161
|
+
func_return, agent_state = self.parse_best_effort(execution.results[0].text)
|
|
162
|
+
return SandboxRunResult(
|
|
163
|
+
func_return=func_return,
|
|
164
|
+
agent_state=agent_state,
|
|
165
|
+
stdout=execution.logs.stdout + execution.logs.stderr,
|
|
166
|
+
sandbox_config_fingerprint=sbx_config.fingerprint(),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def get_running_e2b_sandbox_with_same_state(self, sandbox_config: SandboxConfig) -> Optional["Sandbox"]:
|
|
170
|
+
from e2b_code_interpreter import Sandbox
|
|
171
|
+
|
|
172
|
+
# List running sandboxes and access metadata.
|
|
173
|
+
running_sandboxes = self.list_running_e2b_sandboxes()
|
|
174
|
+
|
|
175
|
+
# Hash the config to check the state
|
|
176
|
+
state_hash = sandbox_config.fingerprint()
|
|
177
|
+
for sandbox in running_sandboxes:
|
|
178
|
+
if self.METADATA_CONFIG_STATE_KEY in sandbox.metadata and sandbox.metadata[self.METADATA_CONFIG_STATE_KEY] == state_hash:
|
|
179
|
+
return Sandbox.connect(sandbox.sandbox_id)
|
|
180
|
+
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def create_e2b_sandbox_with_metadata_hash(self, sandbox_config: SandboxConfig) -> "Sandbox":
|
|
184
|
+
from e2b_code_interpreter import Sandbox
|
|
185
|
+
|
|
186
|
+
state_hash = sandbox_config.fingerprint()
|
|
187
|
+
e2b_config = sandbox_config.get_e2b_config()
|
|
188
|
+
if e2b_config.template:
|
|
189
|
+
sbx = Sandbox(sandbox_config.get_e2b_config().template, metadata={self.METADATA_CONFIG_STATE_KEY: state_hash})
|
|
190
|
+
else:
|
|
191
|
+
# no template
|
|
192
|
+
sbx = Sandbox(metadata={self.METADATA_CONFIG_STATE_KEY: state_hash}, **e2b_config.model_dump(exclude={"pip_requirements"}))
|
|
193
|
+
|
|
194
|
+
# install pip requirements
|
|
195
|
+
if e2b_config.pip_requirements:
|
|
196
|
+
for package in e2b_config.pip_requirements:
|
|
197
|
+
sbx.commands.run(f"pip install {package}")
|
|
198
|
+
return sbx
|
|
199
|
+
|
|
200
|
+
def list_running_e2b_sandboxes(self):
|
|
201
|
+
from e2b_code_interpreter import Sandbox
|
|
202
|
+
|
|
203
|
+
# List running sandboxes and access metadata.
|
|
204
|
+
return Sandbox.list()
|
|
205
|
+
|
|
206
|
+
# general utility functions
|
|
207
|
+
|
|
208
|
+
def parse_best_effort(self, text: str) -> Any:
|
|
209
|
+
result = pickle.loads(base64.b64decode(text))
|
|
210
|
+
agent_state = None
|
|
211
|
+
if not result["agent_state"] is None:
|
|
212
|
+
agent_state = result["agent_state"]
|
|
213
|
+
return result["results"], agent_state
|
|
214
|
+
|
|
215
|
+
def parse_function_arguments(self, source_code: str, tool_name: str):
|
|
216
|
+
"""Get arguments of a function from its source code"""
|
|
217
|
+
tree = ast.parse(source_code)
|
|
218
|
+
args = []
|
|
219
|
+
for node in ast.walk(tree):
|
|
220
|
+
if isinstance(node, ast.FunctionDef) and node.name == tool_name:
|
|
221
|
+
for arg in node.args.args:
|
|
222
|
+
args.append(arg.arg)
|
|
223
|
+
return args
|
|
224
|
+
|
|
225
|
+
def generate_execution_script(self, agent_state: AgentState) -> str:
|
|
226
|
+
"""
|
|
227
|
+
Generate code to run inside of execution sandbox.
|
|
228
|
+
Passes into a serialized agent state into the code, to be accessed by the tool.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
agent_state (AgentState): The agent state
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
code (str): The generated code strong
|
|
235
|
+
"""
|
|
236
|
+
# dump JSON representation of agent state to re-load
|
|
237
|
+
code = "from typing import *\n"
|
|
238
|
+
code += "import pickle\n"
|
|
239
|
+
code += "import sys\n"
|
|
240
|
+
code += "import base64\n"
|
|
241
|
+
|
|
242
|
+
# Load the agent state data into the program
|
|
243
|
+
if agent_state:
|
|
244
|
+
code += "import letta\n"
|
|
245
|
+
code += "from letta import * \n"
|
|
246
|
+
import pickle
|
|
247
|
+
|
|
248
|
+
agent_state_pickle = pickle.dumps(agent_state)
|
|
249
|
+
code += f"agent_state = pickle.loads({agent_state_pickle})\n"
|
|
250
|
+
else:
|
|
251
|
+
# agent state is None
|
|
252
|
+
code += "agent_state = None\n"
|
|
253
|
+
|
|
254
|
+
for param in self.args:
|
|
255
|
+
code += self.initialize_param(param, self.args[param])
|
|
256
|
+
|
|
257
|
+
if "agent_state" in self.parse_function_arguments(self.tool.source_code, self.tool.name):
|
|
258
|
+
inject_agent_state = True
|
|
259
|
+
else:
|
|
260
|
+
inject_agent_state = False
|
|
261
|
+
|
|
262
|
+
code += "\n" + self.tool.source_code + "\n"
|
|
263
|
+
|
|
264
|
+
# TODO: handle wrapped print
|
|
265
|
+
|
|
266
|
+
code += (
|
|
267
|
+
self.LOCAL_SANDBOX_RESULT_VAR_NAME
|
|
268
|
+
+ ' = {"results": '
|
|
269
|
+
+ self.invoke_function_call(inject_agent_state=inject_agent_state)
|
|
270
|
+
+ ', "agent_state": agent_state}\n'
|
|
271
|
+
)
|
|
272
|
+
code += (
|
|
273
|
+
f"{self.LOCAL_SANDBOX_RESULT_VAR_NAME} = base64.b64encode(pickle.dumps({self.LOCAL_SANDBOX_RESULT_VAR_NAME})).decode('utf-8')\n"
|
|
274
|
+
)
|
|
275
|
+
code += f"{self.LOCAL_SANDBOX_RESULT_VAR_NAME}\n"
|
|
276
|
+
|
|
277
|
+
return code
|
|
278
|
+
|
|
279
|
+
def initialize_param(self, name: str, raw_value: str) -> str:
|
|
280
|
+
params = self.tool.json_schema["parameters"]["properties"]
|
|
281
|
+
spec = params.get(name)
|
|
282
|
+
if spec is None:
|
|
283
|
+
# ignore extra params (like 'self') for now
|
|
284
|
+
return ""
|
|
285
|
+
|
|
286
|
+
param_type = spec.get("type")
|
|
287
|
+
if param_type is None and spec.get("parameters"):
|
|
288
|
+
param_type = spec["parameters"].get("type")
|
|
289
|
+
|
|
290
|
+
if param_type == "string":
|
|
291
|
+
value = '"' + raw_value + '"'
|
|
292
|
+
elif param_type == "integer" or param_type == "boolean":
|
|
293
|
+
value = raw_value
|
|
294
|
+
else:
|
|
295
|
+
raise TypeError(f"unsupported type: {param_type}")
|
|
296
|
+
|
|
297
|
+
return name + " = " + str(value) + "\n"
|
|
298
|
+
|
|
299
|
+
def invoke_function_call(self, inject_agent_state: bool) -> str:
|
|
300
|
+
"""
|
|
301
|
+
Generate the code string to call the function.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
inject_agent_state (bool): Whether to inject the agent's state as an input into the tool
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
str: Generated code string for calling the tool
|
|
308
|
+
"""
|
|
309
|
+
kwargs = []
|
|
310
|
+
for name in self.args:
|
|
311
|
+
if name in self.tool.json_schema["parameters"]["properties"]:
|
|
312
|
+
kwargs.append(name)
|
|
313
|
+
|
|
314
|
+
param_list = [f"{arg}={arg}" for arg in kwargs]
|
|
315
|
+
if inject_agent_state:
|
|
316
|
+
param_list.append("agent_state=agent_state")
|
|
317
|
+
params = ", ".join(param_list)
|
|
318
|
+
# if "agent_state" in kwargs:
|
|
319
|
+
# params += ", agent_state=agent_state"
|
|
320
|
+
# TODO: fix to figure out when to insert agent state or not
|
|
321
|
+
# params += "agent_state=agent_state"
|
|
322
|
+
|
|
323
|
+
func_call_str = self.tool.name + "(" + params + ")"
|
|
324
|
+
return func_call_str
|
|
325
|
+
|
|
326
|
+
#
|