supermem 0.3.0__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.
- agent/__init__.py +15 -0
- agent/agent.py +203 -0
- agent/engine.py +332 -0
- agent/model.py +101 -0
- agent/pyproject.toml +34 -0
- agent/schemas.py +154 -0
- agent/settings.py +32 -0
- agent/system_prompt.txt +305 -0
- agent/tools.py +384 -0
- agent/utils.py +229 -0
- mcp_server/__init__.py +0 -0
- mcp_server/http_server.py +172 -0
- mcp_server/mcp_http_server.py +313 -0
- mcp_server/mcp_sse_server.py +330 -0
- mcp_server/pyproject.toml +31 -0
- mcp_server/scripts/filters.py +54 -0
- mcp_server/scripts/install_lms.sh +8 -0
- mcp_server/scripts/memory_setup.py +131 -0
- mcp_server/scripts/memory_setup_cli.py +116 -0
- mcp_server/scripts/setup_scripts_and_json.py +99 -0
- mcp_server/server.py +484 -0
- mcp_server/settings.py +8 -0
- memory_connectors/__init__.py +18 -0
- memory_connectors/base.py +105 -0
- memory_connectors/chatgpt_history/__init__.py +21 -0
- memory_connectors/chatgpt_history/connector.py +431 -0
- memory_connectors/chatgpt_history/converter.py +371 -0
- memory_connectors/chatgpt_history/embedding_connector.py +786 -0
- memory_connectors/chatgpt_history/parser.py +300 -0
- memory_connectors/chatgpt_history/types.py +81 -0
- memory_connectors/github_live/__init__.py +3 -0
- memory_connectors/github_live/connector.py +1093 -0
- memory_connectors/google_docs_live/__init__.py +3 -0
- memory_connectors/google_docs_live/connector.py +594 -0
- memory_connectors/memory_connect.py +657 -0
- memory_connectors/memory_wizard.py +593 -0
- memory_connectors/notion/__init__.py +5 -0
- memory_connectors/notion/connector.py +415 -0
- memory_connectors/notion/parser.py +321 -0
- memory_connectors/notion/types.py +132 -0
- memory_connectors/nuclino/__init__.py +5 -0
- memory_connectors/nuclino/connector.py +446 -0
- memory_connectors/nuclino/parser.py +456 -0
- memory_connectors/nuclino/types.py +106 -0
- supermem/__init__.py +3 -0
- supermem/__main__.py +232 -0
- supermem/capture/__init__.py +8 -0
- supermem/capture/compressor.py +99 -0
- supermem/capture/observation.py +72 -0
- supermem/capture/session.py +69 -0
- supermem/capture/timeline.py +37 -0
- supermem/config.py +109 -0
- supermem/core/__init__.py +14 -0
- supermem/core/connector.py +50 -0
- supermem/core/model_client.py +53 -0
- supermem/core/retriever.py +58 -0
- supermem/core/storage.py +34 -0
- supermem/errors.py +103 -0
- supermem/hooks/__init__.py +1 -0
- supermem/hooks/inject.py +121 -0
- supermem/hooks/learn.py +95 -0
- supermem/indexer/__init__.py +5 -0
- supermem/indexer/vault.py +197 -0
- supermem/logging.py +77 -0
- supermem/model/__init__.py +5 -0
- supermem/model/base.py +255 -0
- supermem/privacy/__init__.py +5 -0
- supermem/privacy/filter.py +41 -0
- supermem/pyproject.toml +38 -0
- supermem/retrieval/__init__.py +15 -0
- supermem/retrieval/agent.py +91 -0
- supermem/retrieval/fts.py +55 -0
- supermem/retrieval/graph.py +102 -0
- supermem/retrieval/hybrid.py +168 -0
- supermem/retrieval/vector.py +59 -0
- supermem/storage/__init__.py +7 -0
- supermem/storage/database.py +452 -0
- supermem/storage/graph.py +175 -0
- supermem/storage/vector.py +108 -0
- supermem-0.3.0.dist-info/METADATA +276 -0
- supermem-0.3.0.dist-info/RECORD +84 -0
- supermem-0.3.0.dist-info/WHEEL +4 -0
- supermem-0.3.0.dist-info/entry_points.txt +2 -0
- supermem-0.3.0.dist-info/licenses/LICENSE +202 -0
agent/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Obsidian Agent package.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from .agent import Agent
|
|
7
|
+
from .engine import execute_sandboxed_code
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Agent",
|
|
11
|
+
"execute_sandboxed_code",
|
|
12
|
+
]
|
|
13
|
+
except ImportError:
|
|
14
|
+
# If some modules can't be imported, just make the package importable
|
|
15
|
+
__all__ = []
|
agent/agent.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from agent.engine import execute_sandboxed_code
|
|
3
|
+
from agent.model import get_model_response, create_openai_client, create_vllm_client
|
|
4
|
+
from agent.utils import (
|
|
5
|
+
load_system_prompt,
|
|
6
|
+
create_memory_if_not_exists,
|
|
7
|
+
extract_python_code,
|
|
8
|
+
format_results,
|
|
9
|
+
extract_reply,
|
|
10
|
+
extract_thoughts,
|
|
11
|
+
)
|
|
12
|
+
from agent.settings import (
|
|
13
|
+
MEMORY_PATH,
|
|
14
|
+
SAVE_CONVERSATION_PATH,
|
|
15
|
+
MAX_TOOL_TURNS,
|
|
16
|
+
VLLM_HOST,
|
|
17
|
+
VLLM_PORT,
|
|
18
|
+
OPENROUTER_STRONG_MODEL,
|
|
19
|
+
)
|
|
20
|
+
from agent.schemas import ChatMessage, Role, AgentResponse
|
|
21
|
+
|
|
22
|
+
from typing import Union, Tuple
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import uuid
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Agent:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
max_tool_turns: int = MAX_TOOL_TURNS,
|
|
33
|
+
memory_path: str = None,
|
|
34
|
+
use_vllm: bool = False,
|
|
35
|
+
model: str = None,
|
|
36
|
+
predetermined_memory_path: bool = False,
|
|
37
|
+
):
|
|
38
|
+
# Load the system prompt and add it to the conversation history
|
|
39
|
+
self.system_prompt = load_system_prompt()
|
|
40
|
+
self.messages: list[ChatMessage] = [
|
|
41
|
+
ChatMessage(role=Role.SYSTEM, content=self.system_prompt)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Set the maximum number of tool turns and use_vllm flag
|
|
45
|
+
self.max_tool_turns = max_tool_turns
|
|
46
|
+
self.use_vllm = use_vllm
|
|
47
|
+
|
|
48
|
+
# Set model: use provided model, or fallback to OPENROUTER_STRONG_MODEL
|
|
49
|
+
if model:
|
|
50
|
+
self.model = model
|
|
51
|
+
else:
|
|
52
|
+
self.model = OPENROUTER_STRONG_MODEL
|
|
53
|
+
|
|
54
|
+
# Each Agent instance gets its own clients to avoid bottlenecks
|
|
55
|
+
if use_vllm:
|
|
56
|
+
self._client = create_vllm_client(host=VLLM_HOST, port=VLLM_PORT)
|
|
57
|
+
else:
|
|
58
|
+
self._client = create_openai_client()
|
|
59
|
+
|
|
60
|
+
# Set memory_path: use provided path or fall back to default MEMORY_PATH
|
|
61
|
+
if memory_path is not None:
|
|
62
|
+
# Always place custom memory paths inside a "memory/" folder
|
|
63
|
+
if predetermined_memory_path:
|
|
64
|
+
self.memory_path = os.path.join("memory", memory_path)
|
|
65
|
+
else:
|
|
66
|
+
self.memory_path = memory_path
|
|
67
|
+
else:
|
|
68
|
+
# Use default MEMORY_PATH but also place it inside "memory/" folder
|
|
69
|
+
self.memory_path = os.path.join("memory", MEMORY_PATH)
|
|
70
|
+
|
|
71
|
+
# Ensure memory_path is absolute for consistency
|
|
72
|
+
self.memory_path = os.path.abspath(self.memory_path)
|
|
73
|
+
|
|
74
|
+
def _add_message(self, message: Union[ChatMessage, dict]):
|
|
75
|
+
"""Add a message to the conversation history."""
|
|
76
|
+
if isinstance(message, dict):
|
|
77
|
+
self.messages.append(ChatMessage(**message))
|
|
78
|
+
elif isinstance(message, ChatMessage):
|
|
79
|
+
self.messages.append(message)
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError("Invalid message type")
|
|
82
|
+
|
|
83
|
+
def extract_response_parts(self, response: str) -> Tuple[str, str, str]:
|
|
84
|
+
"""
|
|
85
|
+
Extract the thoughts, reply and python code from the response.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
response: The response from the agent.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A tuple of the thoughts, reply and python code.
|
|
92
|
+
"""
|
|
93
|
+
thoughts = extract_thoughts(response)
|
|
94
|
+
reply = extract_reply(response)
|
|
95
|
+
python_code = extract_python_code(response)
|
|
96
|
+
|
|
97
|
+
return thoughts, reply, python_code
|
|
98
|
+
|
|
99
|
+
def chat(self, message: str) -> AgentResponse:
|
|
100
|
+
"""
|
|
101
|
+
Chat with the agent.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
message: The message to chat with the agent.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The response from the agent.
|
|
108
|
+
"""
|
|
109
|
+
# Add the user message to the conversation history
|
|
110
|
+
self._add_message(ChatMessage(role=Role.USER, content=message))
|
|
111
|
+
|
|
112
|
+
# Get the response from the agent using this instance's clients
|
|
113
|
+
response = get_model_response(
|
|
114
|
+
messages=self.messages,
|
|
115
|
+
model=self.model, # Pass the model if specified
|
|
116
|
+
client=self._client,
|
|
117
|
+
use_vllm=self.use_vllm,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Extract the thoughts, reply and python code from the response
|
|
121
|
+
thoughts, reply, python_code = self.extract_response_parts(response)
|
|
122
|
+
|
|
123
|
+
# Execute the code from the agent's response
|
|
124
|
+
result = ({}, "")
|
|
125
|
+
if python_code:
|
|
126
|
+
create_memory_if_not_exists(self.memory_path)
|
|
127
|
+
result = execute_sandboxed_code(
|
|
128
|
+
code=python_code,
|
|
129
|
+
allowed_path=self.memory_path,
|
|
130
|
+
import_module="agent.tools",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Add the agent's response to the conversation history
|
|
134
|
+
self._add_message(ChatMessage(role=Role.ASSISTANT, content=response))
|
|
135
|
+
|
|
136
|
+
remaining_tool_turns = self.max_tool_turns
|
|
137
|
+
while remaining_tool_turns > 0 and not reply:
|
|
138
|
+
self._add_message(
|
|
139
|
+
ChatMessage(
|
|
140
|
+
role=Role.USER, content=format_results(result[0], result[1])
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
response = get_model_response(
|
|
144
|
+
messages=self.messages,
|
|
145
|
+
model=self.model, # Pass the model if specified
|
|
146
|
+
client=self._client,
|
|
147
|
+
use_vllm=self.use_vllm,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Extract the thoughts, reply and python code from the response
|
|
151
|
+
thoughts, reply, python_code = self.extract_response_parts(response)
|
|
152
|
+
|
|
153
|
+
self._add_message(ChatMessage(role=Role.ASSISTANT, content=response))
|
|
154
|
+
if python_code:
|
|
155
|
+
create_memory_if_not_exists(self.memory_path)
|
|
156
|
+
result = execute_sandboxed_code(
|
|
157
|
+
code=python_code,
|
|
158
|
+
allowed_path=self.memory_path,
|
|
159
|
+
import_module="agent.tools",
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
# Reset result when no Python code is executed
|
|
163
|
+
result = ({}, "")
|
|
164
|
+
remaining_tool_turns -= 1
|
|
165
|
+
|
|
166
|
+
return AgentResponse(thoughts=thoughts, reply=reply, python_block=python_code)
|
|
167
|
+
|
|
168
|
+
def save_conversation(self, log: bool = False, save_folder: str = None):
|
|
169
|
+
"""
|
|
170
|
+
Save the conversation messages to a JSON file in
|
|
171
|
+
the output/conversations directory.
|
|
172
|
+
"""
|
|
173
|
+
if not os.path.exists(SAVE_CONVERSATION_PATH):
|
|
174
|
+
os.makedirs(SAVE_CONVERSATION_PATH, exist_ok=True)
|
|
175
|
+
|
|
176
|
+
unique_id = uuid.uuid4()
|
|
177
|
+
if not save_folder:
|
|
178
|
+
file_path = os.path.join(SAVE_CONVERSATION_PATH, f"convo_{unique_id}.json")
|
|
179
|
+
else:
|
|
180
|
+
folder_path = (
|
|
181
|
+
save_folder # os.path.join(SAVE_CONVERSATION_PATH, save_folder)
|
|
182
|
+
)
|
|
183
|
+
if not os.path.exists(folder_path):
|
|
184
|
+
os.makedirs(folder_path)
|
|
185
|
+
file_path = os.path.join(folder_path, f"convo_{unique_id}.json")
|
|
186
|
+
|
|
187
|
+
# Convert the execution result messages to tool role
|
|
188
|
+
messages = [
|
|
189
|
+
(
|
|
190
|
+
ChatMessage(role=Role.TOOL, content=message.content)
|
|
191
|
+
if message.content.startswith("<result>")
|
|
192
|
+
else ChatMessage(role=message.role, content=message.content)
|
|
193
|
+
)
|
|
194
|
+
for message in self.messages
|
|
195
|
+
]
|
|
196
|
+
try:
|
|
197
|
+
with open(file_path, "w") as f:
|
|
198
|
+
json.dump([message.model_dump() for message in messages], f, indent=4)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
if log:
|
|
201
|
+
print(f"Error saving conversation: {e}")
|
|
202
|
+
if log:
|
|
203
|
+
print(f"Conversation saved to {file_path}")
|
agent/engine.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
import importlib
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import traceback
|
|
7
|
+
import types
|
|
8
|
+
import pickle
|
|
9
|
+
import subprocess
|
|
10
|
+
import base64
|
|
11
|
+
|
|
12
|
+
from agent.settings import SANDBOX_TIMEOUT
|
|
13
|
+
|
|
14
|
+
# Configure a logger for the sandbox (in real use, configure handlers/level as needed)
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
logger.setLevel(logging.INFO) # or DEBUG for more verbosity
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _run_user_code(
|
|
20
|
+
code: str,
|
|
21
|
+
allow_installs: bool,
|
|
22
|
+
allowed_path: str,
|
|
23
|
+
blacklist: list,
|
|
24
|
+
available_functions: dict,
|
|
25
|
+
log: bool = False,
|
|
26
|
+
) -> tuple[dict, str]:
|
|
27
|
+
"""
|
|
28
|
+
Execute code under sandboxed conditions (limited file access, optional installs,
|
|
29
|
+
and blacklisting) and return the resulting locals and an error message.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
# Optional: apply working directory and file access restriction
|
|
33
|
+
if allowed_path:
|
|
34
|
+
allowed = os.path.abspath(allowed_path)
|
|
35
|
+
try:
|
|
36
|
+
os.chdir(allowed) # Change working dir to the allowed_path
|
|
37
|
+
except Exception as e:
|
|
38
|
+
# If we cannot chdir, log but continue (the open wrapper will still enforce path)
|
|
39
|
+
logger.warning(
|
|
40
|
+
"Could not change working directory to %s: %s", allowed, e
|
|
41
|
+
)
|
|
42
|
+
# Wrap builtins.open to restrict file access
|
|
43
|
+
orig_open = builtins.open
|
|
44
|
+
|
|
45
|
+
def secure_open(file, *args, **kwargs):
|
|
46
|
+
"""Open that restricts file access to allowed_path."""
|
|
47
|
+
# If file is a file object or path-like, get its string path
|
|
48
|
+
path = (
|
|
49
|
+
file if isinstance(file, str) else getattr(file, "name", str(file))
|
|
50
|
+
)
|
|
51
|
+
full_path = os.path.abspath(path if path is not None else "")
|
|
52
|
+
if not full_path.startswith(allowed):
|
|
53
|
+
raise PermissionError(
|
|
54
|
+
f"Access to '{full_path}' is denied by sandbox."
|
|
55
|
+
)
|
|
56
|
+
return orig_open(file, *args, **kwargs)
|
|
57
|
+
|
|
58
|
+
builtins.open = secure_open
|
|
59
|
+
|
|
60
|
+
# Optionally, restrict other file-related functions (remove, rename, etc.) similarly
|
|
61
|
+
# We'll patch a couple of common ones as an example:
|
|
62
|
+
orig_remove = os.remove
|
|
63
|
+
|
|
64
|
+
def secure_remove(path, *args, **kwargs):
|
|
65
|
+
full_path = os.path.abspath(path)
|
|
66
|
+
if not full_path.startswith(allowed):
|
|
67
|
+
raise PermissionError(
|
|
68
|
+
f"Removal of '{full_path}' is denied by sandbox."
|
|
69
|
+
)
|
|
70
|
+
return orig_remove(path, *args, **kwargs)
|
|
71
|
+
|
|
72
|
+
os.remove = secure_remove
|
|
73
|
+
|
|
74
|
+
orig_rename = os.rename
|
|
75
|
+
|
|
76
|
+
def secure_rename(src, dst, *args, **kwargs):
|
|
77
|
+
full_src = os.path.abspath(src)
|
|
78
|
+
full_dst = os.path.abspath(dst)
|
|
79
|
+
if not full_src.startswith(allowed) or not full_dst.startswith(allowed):
|
|
80
|
+
raise PermissionError(
|
|
81
|
+
"Rename operation outside allowed path is denied by sandbox."
|
|
82
|
+
)
|
|
83
|
+
return orig_rename(src, dst, *args, **kwargs)
|
|
84
|
+
|
|
85
|
+
os.rename = secure_rename
|
|
86
|
+
|
|
87
|
+
# Apply blacklist restrictions by removing or disabling blacklisted builtins or attributes
|
|
88
|
+
if blacklist:
|
|
89
|
+
for name in blacklist:
|
|
90
|
+
# If the name has a dot, like "os.system", handle module attributes
|
|
91
|
+
if "." in name:
|
|
92
|
+
mod_name, attr_name = name.split(".", 1)
|
|
93
|
+
try:
|
|
94
|
+
mod_obj = importlib.import_module(mod_name)
|
|
95
|
+
except ImportError:
|
|
96
|
+
mod_obj = None
|
|
97
|
+
# If module is imported in sandbox, remove the attribute
|
|
98
|
+
if mod_obj and hasattr(mod_obj, attr_name):
|
|
99
|
+
try:
|
|
100
|
+
setattr(
|
|
101
|
+
mod_obj, attr_name, None
|
|
102
|
+
) # simple way: nullify the attribute
|
|
103
|
+
except Exception:
|
|
104
|
+
pass # if we cannot set it, ignore (might be read-only)
|
|
105
|
+
else:
|
|
106
|
+
# It's a built-in or global name; remove from builtins if present
|
|
107
|
+
if name in builtins.__dict__:
|
|
108
|
+
builtins.__dict__[name] = (
|
|
109
|
+
None # or we could del, but setting None prevents use
|
|
110
|
+
)
|
|
111
|
+
# Additionally, we can ensure __builtins__ in the exec env doesn't contain them (handled below in exec)
|
|
112
|
+
|
|
113
|
+
# If allowed, handle package installations inside sandbox (in case code itself triggers ImportError)
|
|
114
|
+
if allow_installs:
|
|
115
|
+
# We will install missing imports on the fly during execution if an ImportError occurs.
|
|
116
|
+
# One approach: wrap __import__ to catch failed imports and pip install.
|
|
117
|
+
orig_import = builtins.__import__
|
|
118
|
+
|
|
119
|
+
def custom_import(name, globals=None, locals=None, fromlist=(), level=0):
|
|
120
|
+
try:
|
|
121
|
+
return orig_import(name, globals, locals, fromlist, level)
|
|
122
|
+
except ImportError as e:
|
|
123
|
+
pkg = name.split(".")[0]
|
|
124
|
+
logger.info(
|
|
125
|
+
"Sandbox: attempting to install missing package '%s'", pkg
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
subprocess.run(
|
|
129
|
+
[sys.executable, "-m", "pip", "install", pkg],
|
|
130
|
+
check=True,
|
|
131
|
+
stdout=subprocess.DEVNULL,
|
|
132
|
+
stderr=subprocess.DEVNULL,
|
|
133
|
+
)
|
|
134
|
+
except Exception as inst_err:
|
|
135
|
+
# If installation fails, re-raise the original ImportError
|
|
136
|
+
logger.error(
|
|
137
|
+
"Sandbox: failed to install package %s: %s", pkg, inst_err
|
|
138
|
+
)
|
|
139
|
+
raise e
|
|
140
|
+
# Retry the import after installation
|
|
141
|
+
return orig_import(name, globals, locals, fromlist, level)
|
|
142
|
+
|
|
143
|
+
builtins.__import__ = custom_import
|
|
144
|
+
|
|
145
|
+
# Prepare an isolated execution namespace. We use an empty globals dict with a fresh builtins.
|
|
146
|
+
exec_globals = {"__builtins__": builtins.__dict__}
|
|
147
|
+
|
|
148
|
+
# Add any provided functions to the execution environment
|
|
149
|
+
if available_functions:
|
|
150
|
+
exec_globals.update(available_functions)
|
|
151
|
+
|
|
152
|
+
exec_locals = {} # local variables will be collected here
|
|
153
|
+
|
|
154
|
+
error_msg = None
|
|
155
|
+
try:
|
|
156
|
+
exec(code, exec_globals, exec_locals) # Execute the user's code
|
|
157
|
+
except Exception as e:
|
|
158
|
+
# Catch any exception and format it
|
|
159
|
+
tb = traceback.format_exc()
|
|
160
|
+
error_msg = f"Exception in sandboxed code:\n{tb}"
|
|
161
|
+
if log:
|
|
162
|
+
logger.error("Sandbox: code raised an exception: %s", e)
|
|
163
|
+
except SystemExit as e:
|
|
164
|
+
# Handle sys.exit calls (which raise SystemExit)
|
|
165
|
+
code_val = e.code if isinstance(e.code, int) or e.code else 0
|
|
166
|
+
if code_val != 0:
|
|
167
|
+
error_msg = f"Sandboxed code called sys.exit({code_val})"
|
|
168
|
+
if log:
|
|
169
|
+
logger.warning(
|
|
170
|
+
"Sandbox: code exited with non-zero status %s", code_val
|
|
171
|
+
)
|
|
172
|
+
# For sys.exit(0), we treat it as normal termination (no error)
|
|
173
|
+
|
|
174
|
+
# Clean up any blacklisted or internal entries in locals
|
|
175
|
+
exec_locals.pop("__builtins__", None)
|
|
176
|
+
|
|
177
|
+
# Collect only picklable locals for returning
|
|
178
|
+
safe_locals = {}
|
|
179
|
+
for var, val in exec_locals.items():
|
|
180
|
+
try:
|
|
181
|
+
pickle.dumps(val) # test picklability
|
|
182
|
+
safe_locals[var] = val
|
|
183
|
+
except Exception:
|
|
184
|
+
safe_locals[var] = repr(val) # fallback: use string representation
|
|
185
|
+
|
|
186
|
+
if log:
|
|
187
|
+
logger.info("Sandbox execution finished")
|
|
188
|
+
|
|
189
|
+
return safe_locals, error_msg
|
|
190
|
+
|
|
191
|
+
except Exception as e:
|
|
192
|
+
# Catch any unhandled exceptions in the worker process
|
|
193
|
+
if log:
|
|
194
|
+
logger.error(
|
|
195
|
+
"Unhandled exception in sandbox worker: %s", traceback.format_exc()
|
|
196
|
+
)
|
|
197
|
+
return None, f"Sandbox worker error: {str(e)}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def execute_sandboxed_code(
|
|
201
|
+
code: str,
|
|
202
|
+
timeout: int = SANDBOX_TIMEOUT,
|
|
203
|
+
allow_installs: bool = False,
|
|
204
|
+
requirements_path: str = None,
|
|
205
|
+
allowed_path: str = None,
|
|
206
|
+
blacklist: list = None,
|
|
207
|
+
available_functions: dict = None,
|
|
208
|
+
import_module: str = None,
|
|
209
|
+
log: bool = False,
|
|
210
|
+
) -> tuple[dict, str]:
|
|
211
|
+
"""
|
|
212
|
+
Execute the given Python code string in a sandboxed subprocess with specified restrictions.
|
|
213
|
+
|
|
214
|
+
Parameters:
|
|
215
|
+
code (str): The Python code to execute.
|
|
216
|
+
timeout (int): Maximum execution time in seconds for the sandboxed code (default 10 seconds).
|
|
217
|
+
allow_installs (bool): If True, allow installing missing packages via pip (default False).
|
|
218
|
+
requirements_path (str): Path to a requirements.txt file to install before execution.
|
|
219
|
+
allowed_path (str): Directory path that the code is allowed to access for file I/O.
|
|
220
|
+
File operations outside this path will be blocked. If None, no extra file restrictions are applied.
|
|
221
|
+
blacklist (list): List of names (builtins or module attributes) that are disallowed in the code.
|
|
222
|
+
If the code uses any of these, it will be prevented or result in an error.
|
|
223
|
+
available_functions (dict): Dictionary of functions to make available in the sandboxed environment.
|
|
224
|
+
The keys are the function names, and the values are the function objects.
|
|
225
|
+
import_module (str): Name of a Python module to import and make all its functions available in the sandbox.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
(dict, str): A tuple containing the dictionary of local variables from the executed code (or None on failure),
|
|
229
|
+
and an error message (str) if an error/exception occurred, or None if execution was successful.
|
|
230
|
+
"""
|
|
231
|
+
# Step 1: If package installs are allowed, handle requirements and prepare environment
|
|
232
|
+
if requirements_path:
|
|
233
|
+
if os.path.isfile(requirements_path):
|
|
234
|
+
logger.info(
|
|
235
|
+
"Installing packages from requirements file: %s", requirements_path
|
|
236
|
+
)
|
|
237
|
+
try:
|
|
238
|
+
subprocess.run(
|
|
239
|
+
[sys.executable, "-m", "pip", "install", "-r", requirements_path],
|
|
240
|
+
check=True,
|
|
241
|
+
stdout=subprocess.DEVNULL,
|
|
242
|
+
stderr=subprocess.DEVNULL,
|
|
243
|
+
)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.error(
|
|
246
|
+
"Failed to install requirements from %s: %s", requirements_path, e
|
|
247
|
+
)
|
|
248
|
+
# If requirements fail to install, we can choose to abort or continue. Here, abort execution.
|
|
249
|
+
return None, f"Failed to install requirements: {e}"
|
|
250
|
+
else:
|
|
251
|
+
logger.error("Requirements file %s not found.", requirements_path)
|
|
252
|
+
return None, f"Requirements file not found: {requirements_path}"
|
|
253
|
+
|
|
254
|
+
# If a module name is provided, import it and add its functions to available_functions
|
|
255
|
+
if isinstance(available_functions, str) and not import_module:
|
|
256
|
+
import_module = available_functions
|
|
257
|
+
available_functions = None
|
|
258
|
+
|
|
259
|
+
if import_module:
|
|
260
|
+
try:
|
|
261
|
+
module = importlib.import_module(import_module)
|
|
262
|
+
if available_functions is None:
|
|
263
|
+
available_functions = {}
|
|
264
|
+
for name in dir(module):
|
|
265
|
+
if not name.startswith("_"):
|
|
266
|
+
attr = getattr(module, name)
|
|
267
|
+
if callable(attr):
|
|
268
|
+
available_functions[name] = attr
|
|
269
|
+
except ImportError as e:
|
|
270
|
+
logger.error(f"Failed to import module {import_module}: {e}")
|
|
271
|
+
return None, f"Failed to import module {import_module}: {e}"
|
|
272
|
+
|
|
273
|
+
# Step 2: Execute the code in a separate Python subprocess
|
|
274
|
+
params = {
|
|
275
|
+
"code": code,
|
|
276
|
+
"allow_installs": allow_installs,
|
|
277
|
+
"allowed_path": allowed_path,
|
|
278
|
+
"blacklist": blacklist or [],
|
|
279
|
+
"available_functions": available_functions or {},
|
|
280
|
+
"log": log,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
env = os.environ.copy()
|
|
284
|
+
env["SANDBOX_PARAMS"] = base64.b64encode(pickle.dumps(params)).decode()
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
result = subprocess.run(
|
|
288
|
+
[sys.executable, "-m", "agent.engine"],
|
|
289
|
+
stdout=subprocess.PIPE,
|
|
290
|
+
stderr=subprocess.PIPE,
|
|
291
|
+
timeout=timeout,
|
|
292
|
+
env=env,
|
|
293
|
+
)
|
|
294
|
+
except subprocess.TimeoutExpired:
|
|
295
|
+
logger.error(
|
|
296
|
+
"Sandboxed code exceeded time limit of %d seconds; terminating.", timeout
|
|
297
|
+
)
|
|
298
|
+
return None, f"TimeoutError: Code execution exceeded {timeout} seconds."
|
|
299
|
+
|
|
300
|
+
if result.returncode != 0:
|
|
301
|
+
return None, result.stderr.decode().strip()
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
local_vars, error_msg = pickle.loads(result.stdout)
|
|
305
|
+
except Exception as e:
|
|
306
|
+
return None, f"Failed to decode sandbox output: {e}"
|
|
307
|
+
|
|
308
|
+
if error_msg is None:
|
|
309
|
+
error_msg = ""
|
|
310
|
+
|
|
311
|
+
return local_vars, error_msg
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _subprocess_entry() -> None:
|
|
315
|
+
"""Entry point for sandbox subprocess."""
|
|
316
|
+
params_b64 = os.environ.get("SANDBOX_PARAMS")
|
|
317
|
+
if not params_b64:
|
|
318
|
+
sys.exit(1)
|
|
319
|
+
params = pickle.loads(base64.b64decode(params_b64))
|
|
320
|
+
locals_dict, error = _run_user_code(
|
|
321
|
+
params["code"],
|
|
322
|
+
params.get("allow_installs", False),
|
|
323
|
+
params.get("allowed_path"),
|
|
324
|
+
params.get("blacklist", []),
|
|
325
|
+
params.get("available_functions", {}),
|
|
326
|
+
params.get("log", False),
|
|
327
|
+
)
|
|
328
|
+
sys.stdout.buffer.write(pickle.dumps((locals_dict, error)))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
if __name__ == "__main__":
|
|
332
|
+
_subprocess_entry()
|
agent/model.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from openai import OpenAI
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from typing import Optional, Union
|
|
5
|
+
|
|
6
|
+
from agent.settings import (
|
|
7
|
+
OPENROUTER_API_KEY,
|
|
8
|
+
OPENROUTER_BASE_URL,
|
|
9
|
+
OPENROUTER_STRONG_MODEL,
|
|
10
|
+
)
|
|
11
|
+
from agent.schemas import ChatMessage, Role
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_openai_client() -> OpenAI:
|
|
15
|
+
"""Create a new OpenAI client instance."""
|
|
16
|
+
return OpenAI(
|
|
17
|
+
api_key=OPENROUTER_API_KEY,
|
|
18
|
+
base_url=OPENROUTER_BASE_URL,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_vllm_client(host: str = "0.0.0.0", port: int = 8000) -> OpenAI:
|
|
23
|
+
"""Create a new vLLM client instance (OpenAI-compatible)."""
|
|
24
|
+
return OpenAI(
|
|
25
|
+
base_url=f"http://{host}:{port}/v1",
|
|
26
|
+
api_key="EMPTY",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _as_dict(msg: Union[ChatMessage, dict]) -> dict:
|
|
31
|
+
"""
|
|
32
|
+
Accept either ChatMessage or raw dict and return the raw dict.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
msg: A ChatMessage object or a raw dict.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A raw dict.
|
|
39
|
+
"""
|
|
40
|
+
return msg if isinstance(msg, dict) else msg.model_dump()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_model_response(
|
|
44
|
+
messages: Optional[list[ChatMessage]] = None,
|
|
45
|
+
message: Optional[str] = None,
|
|
46
|
+
system_prompt: Optional[str] = None,
|
|
47
|
+
model: str = OPENROUTER_STRONG_MODEL,
|
|
48
|
+
client: Optional[OpenAI] = None,
|
|
49
|
+
use_vllm: bool = False,
|
|
50
|
+
) -> Union[str, BaseModel]:
|
|
51
|
+
"""
|
|
52
|
+
Get a response from a model using OpenRouter or vLLM, with optional schema for structured output.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
messages: A list of ChatMessage objects (optional).
|
|
56
|
+
message: A single message string (optional).
|
|
57
|
+
system_prompt: A system prompt for the model (optional).
|
|
58
|
+
model: The model to use.
|
|
59
|
+
schema: A Pydantic BaseModel for structured output (optional).
|
|
60
|
+
client: Optional OpenAI client to use. If None, uses the global client.
|
|
61
|
+
use_vllm: Whether to use vLLM backend instead of OpenRouter.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A string response from the model if schema is None, otherwise a BaseModel object.
|
|
65
|
+
"""
|
|
66
|
+
if messages is None and message is None:
|
|
67
|
+
raise ValueError("Either 'messages' or 'message' must be provided.")
|
|
68
|
+
|
|
69
|
+
# Use provided clients or fall back to global ones
|
|
70
|
+
if client is None:
|
|
71
|
+
if use_vllm:
|
|
72
|
+
client = create_vllm_client()
|
|
73
|
+
else:
|
|
74
|
+
client = create_openai_client()
|
|
75
|
+
|
|
76
|
+
# Build message history
|
|
77
|
+
if messages is None:
|
|
78
|
+
messages = []
|
|
79
|
+
if system_prompt:
|
|
80
|
+
messages.append(
|
|
81
|
+
_as_dict(ChatMessage(role=Role.SYSTEM, content=system_prompt))
|
|
82
|
+
)
|
|
83
|
+
messages.append(_as_dict(ChatMessage(role=Role.USER, content=message)))
|
|
84
|
+
else:
|
|
85
|
+
messages = [_as_dict(m) for m in messages]
|
|
86
|
+
|
|
87
|
+
if use_vllm:
|
|
88
|
+
completion = client.chat.completions.create(
|
|
89
|
+
model=model,
|
|
90
|
+
messages=messages,
|
|
91
|
+
# stop=["</reply>", "</python>"]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return completion.choices[0].message.content
|
|
95
|
+
else:
|
|
96
|
+
completion = client.chat.completions.create(
|
|
97
|
+
model=model,
|
|
98
|
+
messages=messages,
|
|
99
|
+
# stop=["</reply>", "</python>"]
|
|
100
|
+
)
|
|
101
|
+
return completion.choices[0].message.content
|
agent/pyproject.toml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agent"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Agent module for mem-agent-mcp project"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"openai",
|
|
8
|
+
"pydantic>=2.0.0",
|
|
9
|
+
"python-dotenv",
|
|
10
|
+
"aiofiles",
|
|
11
|
+
"jinja2",
|
|
12
|
+
"black",
|
|
13
|
+
"huggingface-hub>=0.34.3",
|
|
14
|
+
"transformers>4.51.0,<4.54.0",
|
|
15
|
+
"vllm>=0.5.5; sys_platform != 'darwin'",
|
|
16
|
+
"setuptools"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
21
|
+
build-backend = "setuptools.build_meta"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
py-modules = ["agent", "tools", "model", "engine", "schemas", "utils", "settings"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.package-data]
|
|
27
|
+
"*" = ["*.txt"]
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=7.0.0",
|
|
32
|
+
"pytest-asyncio>=0.23.0",
|
|
33
|
+
"pytest-cov>=4.0.0",
|
|
34
|
+
]
|