Files

364 lines
14 KiB
Python

# Copyright 2023 The Qwen team, Alibaba Group. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import atexit
import base64
import glob
import io
import json
import os
import queue
import re
import shutil
import signal
import stat
import subprocess
import sys
import threading
import time
import uuid
from pathlib import Path
from typing import Dict, List, Optional, Union
import json5
from qwen_agent.log import logger
from qwen_agent.tools.base import BaseToolWithFileAccess, register_tool
from qwen_agent.utils.utils import append_signal_handler, extract_code, has_chinese_chars, print_traceback
LAUNCH_KERNEL_PY = """
from ipykernel import kernelapp as app
app.launch_new_instance()
"""
INIT_CODE_FILE = str(Path(__file__).absolute().parent / 'resource' / 'code_interpreter_init_kernel.py')
ALIB_FONT_FILE = str(Path(__file__).absolute().parent / 'resource' / 'AlibabaPuHuiTi-3-45-Light.ttf')
_KERNEL_CLIENTS: dict = {}
_MISC_SUBPROCESSES: Dict[str, subprocess.Popen] = {}
def _kill_kernels_and_subprocesses(_sig_num=None, _frame=None):
for v in _KERNEL_CLIENTS.values():
v.shutdown()
for k in list(_KERNEL_CLIENTS.keys()):
del _KERNEL_CLIENTS[k]
for v in _MISC_SUBPROCESSES.values():
v.terminate()
for k in list(_MISC_SUBPROCESSES.keys()):
del _MISC_SUBPROCESSES[k]
# Make sure all subprocesses are terminated even if killed abnormally:
# If not running in the main thread, (for example run in streamlit)
# register a signal would cause a RuntimeError
if threading.current_thread() is threading.main_thread():
atexit.register(_kill_kernels_and_subprocesses)
append_signal_handler(signal.SIGTERM, _kill_kernels_and_subprocesses)
append_signal_handler(signal.SIGINT, _kill_kernels_and_subprocesses)
@register_tool('code_interpreter')
class CodeInterpreter(BaseToolWithFileAccess):
description = 'Python code sandbox, which can be used to execute Python code.'
parameters = {
'type': 'object',
'properties': {
'code': {
'description': 'The python code.',
'type': 'string',
}
},
'required': ['code'],
}
def __init__(self, cfg: Optional[Dict] = None):
super().__init__(cfg)
self.work_dir: str = os.getenv('M6_CODE_INTERPRETER_WORK_DIR', self.work_dir)
self.work_dir: str = self.cfg.get('work_dir', self.work_dir)
self.instance_id: str = str(uuid.uuid4())
_check_deps_for_code_interpreter()
@property
def args_format(self) -> str:
fmt = self.cfg.get('args_format')
if fmt is None:
if has_chinese_chars([self.name_for_human, self.name, self.description, self.parameters]):
fmt = '此工具的输入应为Markdown代码块。'
else:
fmt = 'Enclose the code within triple backticks (`) at the beginning and end of the code.'
return fmt
def call(self, params: Union[str, dict], files: List[str] = None, timeout: Optional[int] = 30, **kwargs) -> str:
super().call(params=params, files=files) # copy remote files to work_dir
try:
params = json5.loads(params)
code = params['code']
except Exception:
code = extract_code(params)
if not code.strip():
return ''
kernel_id: str = f'{self.instance_id}_{os.getpid()}'
if kernel_id in _KERNEL_CLIENTS:
kc = _KERNEL_CLIENTS[kernel_id]
else:
_fix_matplotlib_cjk_font_issue()
self._fix_secure_write_for_code_interpreter()
kc, subproc = self._start_kernel(kernel_id)
with open(INIT_CODE_FILE) as fin:
start_code = fin.read()
start_code = start_code.replace('{{M6_FONT_PATH}}', repr(ALIB_FONT_FILE)[1:-1])
start_code += '\n%xmode Minimal'
logger.info(self._execute_code(kc, start_code))
_KERNEL_CLIENTS[kernel_id] = kc
_MISC_SUBPROCESSES[kernel_id] = subproc
if timeout:
code = f'_M6CountdownTimer.start({timeout})\n{code}'
fixed_code = []
for line in code.split('\n'):
fixed_code.append(line)
if line.startswith('sns.set_theme('):
fixed_code.append('plt.rcParams["font.family"] = _m6_font_prop.get_name()')
fixed_code = '\n'.join(fixed_code)
fixed_code += '\n\n' # Prevent code not executing in notebook due to no line breaks at the end
result = self._execute_code(kc, fixed_code)
if timeout:
self._execute_code(kc, '_M6CountdownTimer.cancel()')
return result if result.strip() else 'Finished execution.'
def __del__(self):
# Recycle the jupyter subprocess:
k: str = f'{self.instance_id}_{os.getpid()}'
if k in _KERNEL_CLIENTS:
_KERNEL_CLIENTS[k].shutdown()
del _KERNEL_CLIENTS[k]
if k in _MISC_SUBPROCESSES:
_MISC_SUBPROCESSES[k].terminate()
del _MISC_SUBPROCESSES[k]
def _fix_secure_write_for_code_interpreter(self):
if 'linux' in sys.platform.lower():
os.makedirs(self.work_dir, exist_ok=True)
fname = os.path.join(self.work_dir, f'test_file_permission_{os.getpid()}.txt')
if os.path.exists(fname):
os.remove(fname)
with os.fdopen(os.open(fname, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o0600), 'w') as f:
f.write('test')
file_mode = stat.S_IMODE(os.stat(fname).st_mode) & 0o6677
if file_mode != 0o0600:
os.environ['JUPYTER_ALLOW_INSECURE_WRITES'] = '1'
if os.path.exists(fname):
os.remove(fname)
def _start_kernel(self, kernel_id: str):
connection_file = os.path.join(self.work_dir, f'kernel_connection_file_{kernel_id}.json')
launch_kernel_script = os.path.join(self.work_dir, f'launch_kernel_{kernel_id}.py')
for f in [connection_file, launch_kernel_script]:
if os.path.exists(f):
logger.info(f'WARNING: {f} already exists')
os.remove(f)
os.makedirs(self.work_dir, exist_ok=True)
with open(launch_kernel_script, 'w') as fout:
fout.write(LAUNCH_KERNEL_PY)
kernel_process = subprocess.Popen(
[
sys.executable,
os.path.abspath(launch_kernel_script),
'--IPKernelApp.connection_file',
os.path.abspath(connection_file),
'--matplotlib=inline',
'--quiet',
],
cwd=os.path.abspath(self.work_dir),
)
logger.info(f"INFO: kernel process's PID = {kernel_process.pid}")
# Wait for kernel connection file to be written
while True:
if not os.path.isfile(connection_file):
time.sleep(0.1)
else:
# Keep looping if JSON parsing fails, file may be partially written
try:
with open(connection_file, 'r') as fp:
json.load(fp)
break
except json.JSONDecodeError:
pass
# Client
from jupyter_client import BlockingKernelClient
kc = BlockingKernelClient(connection_file=connection_file)
asyncio.set_event_loop_policy(AnyThreadEventLoopPolicy())
kc.load_connection_file()
kc.start_channels()
kc.wait_for_ready()
return kc, kernel_process
def _execute_code(self, kc, code: str) -> str:
kc.wait_for_ready()
kc.execute(code)
result = ''
image_idx = 0
while True:
text = ''
image = ''
finished = False
msg_type = 'error'
try:
msg = kc.get_iopub_msg()
msg_type = msg['msg_type']
if msg_type == 'status':
if msg['content'].get('execution_state') == 'idle':
finished = True
elif msg_type == 'execute_result':
text = msg['content']['data'].get('text/plain', '')
if 'image/png' in msg['content']['data']:
image_b64 = msg['content']['data']['image/png']
image_url = self._serve_image(image_b64)
image_idx += 1
image = '![fig-%03d](%s)' % (image_idx, image_url)
elif msg_type == 'display_data':
if 'image/png' in msg['content']['data']:
image_b64 = msg['content']['data']['image/png']
image_url = self._serve_image(image_b64)
image_idx += 1
image = '![fig-%03d](%s)' % (image_idx, image_url)
else:
text = msg['content']['data'].get('text/plain', '')
elif msg_type == 'stream':
msg_type = msg['content']['name'] # stdout, stderr
text = msg['content']['text']
elif msg_type == 'error':
text = _escape_ansi('\n'.join(msg['content']['traceback']))
if 'M6_CODE_INTERPRETER_TIMEOUT' in text:
text = 'Timeout: Code execution exceeded the time limit.'
except queue.Empty:
text = 'Timeout: Code execution exceeded the time limit.'
finished = True
except Exception:
text = 'The code interpreter encountered an unexpected error.'
print_traceback()
finished = True
if text:
result += f'\n\n{msg_type}:\n\n```\n{text}\n```'
if image:
result += f'\n\n{image}'
if finished:
break
result = result.lstrip('\n')
return result
def _serve_image(self, image_base64: str) -> str:
import PIL.Image
image_file = f'{uuid.uuid4()}.png'
local_image_file = os.path.join(self.work_dir, image_file)
png_bytes = base64.b64decode(image_base64)
assert isinstance(png_bytes, bytes)
bytes_io = io.BytesIO(png_bytes)
PIL.Image.open(bytes_io).save(local_image_file, 'png')
image_server_url = os.getenv('M6_CODE_INTERPRETER_STATIC_URL', '')
if image_server_url:
return f'{image_server_url}/{image_file}'
return local_image_file
def _check_deps_for_code_interpreter():
try:
import matplotlib # noqa
import matplotlib.pyplot as plt # noqa
import numpy as np # noqa
import pandas as pd # noqa
import PIL.Image # noqa
import seaborn as sns # noqa
from jupyter_client import BlockingKernelClient # noqa
from sympy import Eq, solve, symbols # noqa
except ImportError as e:
raise ImportError(
'The dependencies for Code Interpreter support are not installed. '
'Please install the required dependencies by running: pip install "qwen-agent[code_interpreter]"') from e
def _fix_matplotlib_cjk_font_issue():
import matplotlib
ttf_name = os.path.basename(ALIB_FONT_FILE)
local_ttf = os.path.join(os.path.abspath(os.path.join(matplotlib.matplotlib_fname(), os.path.pardir)), 'fonts',
'ttf', ttf_name)
if not os.path.exists(local_ttf):
try:
shutil.copy(ALIB_FONT_FILE, local_ttf)
font_list_cache = os.path.join(matplotlib.get_cachedir(), 'fontlist-*.json')
for cache_file in glob.glob(font_list_cache):
with open(cache_file) as fin:
cache_content = fin.read()
if ttf_name not in cache_content:
os.remove(cache_file)
except Exception:
print_traceback()
def _escape_ansi(line: str) -> str:
ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]')
return ansi_escape.sub('', line)
#
# The _BasePolicy and AnyThreadEventLoopPolicy below are borrowed from Tornado.
# Ref: https://www.tornadoweb.org/en/stable/_modules/tornado/platform/asyncio.html#AnyThreadEventLoopPolicy
#
if sys.platform == 'win32' and hasattr(asyncio, 'WindowsSelectorEventLoopPolicy'):
_BasePolicy = asyncio.WindowsSelectorEventLoopPolicy # type: ignore
else:
_BasePolicy = asyncio.DefaultEventLoopPolicy
class AnyThreadEventLoopPolicy(_BasePolicy): # type: ignore
"""Event loop policy that allows loop creation on any thread.
The default `asyncio` event loop policy only automatically creates
event loops in the main threads. Other threads must create event
loops explicitly or `asyncio.get_event_loop` (and therefore
`.IOLoop.current`) will fail. Installing this policy allows event
loops to be created automatically on any thread.
Usage::
asyncio.set_event_loop_policy(AnyThreadEventLoopPolicy())
"""
def get_event_loop(self) -> asyncio.AbstractEventLoop:
try:
return super().get_event_loop()
except RuntimeError:
# "There is no current event loop in thread %r"
loop = self.new_event_loop()
self.set_event_loop(loop)
return loop